Compare commits
209 Commits
alpha-v202
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d204e95460 | ||
|
|
b5d462b3cd | ||
|
|
c77d2af537 | ||
|
|
7d6ba934c6 | ||
|
|
9ca05c1665 | ||
|
|
194cebdfe0 | ||
|
|
d8e0524613 | ||
|
|
e4afa7cfa5 | ||
|
|
c6faf005b5 | ||
|
|
2e9f07bab4 | ||
|
|
39159b9b1e | ||
|
|
770fdee881 | ||
|
|
0979c0664c | ||
|
|
ce237039c9 | ||
|
|
1c8207eeb5 | ||
|
|
35d258c0ba | ||
|
|
2df076f834 | ||
|
|
49008eb79c | ||
|
|
454181456d | ||
|
|
e07dab2310 | ||
|
|
ae529f64cd | ||
|
|
2a3ecf7270 | ||
|
|
12646d4e98 | ||
|
|
f0fb7b7418 | ||
|
|
5bb3fe0550 | ||
|
|
7709ad1002 | ||
|
|
948b1501bf | ||
|
|
806df2c586 | ||
|
|
3bed1f9235 | ||
|
|
07248fb868 | ||
|
|
8f948d4c41 | ||
|
|
cfb8e0aa4f | ||
|
|
fa9de47729 | ||
|
|
fe84ed91ac | ||
|
|
b5e0c9d890 | ||
|
|
5d9deb3028 | ||
|
|
600ddccff4 | ||
|
|
e3e7981d8a | ||
|
|
bf1feb204b | ||
|
|
2f4d52e53a | ||
|
|
2c9b897430 | ||
|
|
e1486959bb | ||
|
|
74dac301d3 | ||
|
|
2011acf1e2 | ||
|
|
50c46f9924 | ||
|
|
ecfc76af59 | ||
|
|
b2a94b482e | ||
|
|
425393b4a2 | ||
|
|
ac196bec58 | ||
|
|
4e3dfaad55 | ||
|
|
d5b2773436 | ||
|
|
695c8423f5 | ||
|
|
c7c53cafa2 | ||
|
|
6c29a01a69 | ||
|
|
7a72df1c2f | ||
|
|
5f87debecb | ||
|
|
57c61a1ddd | ||
|
|
08abcd5eff | ||
|
|
bb47cf875d | ||
|
|
0a36915903 | ||
|
|
df1e2913c0 | ||
|
|
ecf4df2d11 | ||
|
|
54595ab174 | ||
|
|
73742495b0 | ||
|
|
b93e25ddb1 | ||
|
|
5c5da8a670 | ||
|
|
3a7ce6bb5f | ||
|
|
ae6bd8ec73 | ||
|
|
c7f957775e | ||
|
|
9ea4c367bd | ||
|
|
55c0153692 | ||
|
|
67007e75ff | ||
|
|
57c6a4a2a4 | ||
|
|
663a7d9a3f | ||
|
|
0abf7b492a | ||
|
|
6cf77f9b94 | ||
|
|
2c1fe9f308 | ||
|
|
efd3cd4f27 | ||
|
|
3429474fc5 | ||
|
|
c6ce5f421b | ||
|
|
7c307e8baf | ||
|
|
9d447741f3 | ||
|
|
e9079df63c | ||
|
|
48fb6710e1 | ||
|
|
ae72a43a43 | ||
|
|
49935db529 | ||
|
|
cd4d3dece2 | ||
|
|
0ca0f4a6cf | ||
|
|
c2a0988368 | ||
|
|
ab123a1428 | ||
|
|
c1a5042fb9 | ||
|
|
9d331ffda8 | ||
|
|
41992506f1 | ||
|
|
761f232647 | ||
|
|
c09b25d3c5 | ||
|
|
4061d5e88e | ||
|
|
0983e4e288 | ||
|
|
247c3eefb9 | ||
|
|
28e2072f9b | ||
|
|
7adecaea60 | ||
|
|
2616f87185 | ||
|
|
d81c696742 | ||
|
|
678cc8f7ba | ||
|
|
35176d6eff | ||
|
|
42130e116d | ||
|
|
1040d95eec | ||
|
|
480876558a | ||
|
|
ce04a49bae | ||
|
|
3695b6f3fe | ||
|
|
7e50da7816 | ||
|
|
17a938f8cf | ||
|
|
3d2efcedeb | ||
|
|
c7b7b97022 | ||
|
|
0c8be62831 | ||
|
|
9b28f60be0 | ||
|
|
9b6a43cec7 | ||
|
|
384c752c33 | ||
|
|
e844cfa0bc | ||
|
|
7174061b2a | ||
|
|
cf03c01b75 | ||
|
|
8b83b2c691 | ||
|
|
d445c72edb | ||
|
|
9a8aacad40 | ||
|
|
416131aa8f | ||
|
|
8b1d2bbed8 | ||
|
|
9a7a31c47b | ||
|
|
62e62790c1 | ||
|
|
3b2f675264 | ||
|
|
9706ee8ec2 | ||
|
|
c9a4b273d6 | ||
|
|
8fe02f8c26 | ||
|
|
18f91af77f | ||
|
|
1c047079f0 | ||
|
|
a8273ad7a9 | ||
|
|
133b2601a2 | ||
|
|
21d4577079 | ||
|
|
c19003cf39 | ||
|
|
d99da92b43 | ||
|
|
8b9c4592f1 | ||
|
|
832feb7df1 | ||
|
|
506e8d0eac | ||
|
|
7cd346bde0 | ||
|
|
47f5da1798 | ||
|
|
ff4939fb29 | ||
|
|
3fa1d34c32 | ||
|
|
2ef4d95bb5 | ||
|
|
ce715ed5aa | ||
|
|
95dab219b8 | ||
|
|
2fcea0624d | ||
|
|
7f8aad0686 | ||
|
|
2eae7a0899 | ||
|
|
6bb9c7fbc9 | ||
|
|
9d9681345d | ||
|
|
201aefca05 | ||
|
|
41f0e00cfb | ||
|
|
c571b06293 | ||
|
|
14bf0dc32f | ||
|
|
238b4df55f | ||
|
|
881f860504 | ||
|
|
596d2b93bb | ||
|
|
16ce8b105c | ||
|
|
e7d74a7880 | ||
|
|
8cb5d0fd88 | ||
|
|
cebcd17db0 | ||
|
|
4276c1c3ac | ||
|
|
fa3cf3f7f3 | ||
|
|
8d761751e7 | ||
|
|
eabab20453 | ||
|
|
fdd6a64c72 | ||
|
|
2db83d0810 | ||
|
|
cdb303d98c | ||
|
|
fe2a35b09e | ||
|
|
816145ea0f | ||
|
|
5d3661995c | ||
|
|
c06b673e62 | ||
|
|
45e7416267 | ||
|
|
743b259fb5 | ||
|
|
154e6f68be | ||
|
|
053404afee | ||
|
|
459eae1334 | ||
|
|
22b366d6f9 | ||
|
|
9540a4e9dc | ||
|
|
de1cad448b | ||
|
|
1133b8ee6f | ||
|
|
ae4dbefe83 | ||
|
|
2f1ffeffcb | ||
|
|
a3a89ef483 | ||
|
|
6c533b7055 | ||
|
|
d54467cbfe | ||
|
|
359103b0d9 | ||
|
|
2302594c5c | ||
|
|
e226804947 | ||
|
|
165697f678 | ||
|
|
a6a8146975 | ||
|
|
ec03ffc7a9 | ||
|
|
beeecb1845 | ||
|
|
f7a54593ac | ||
|
|
6209b0baa5 | ||
|
|
8739605572 | ||
|
|
f18a309469 | ||
|
|
aa1d54f4c9 | ||
|
|
f816be5bbe | ||
|
|
101423c977 | ||
|
|
e66c81d896 | ||
|
|
e399d4c1fe | ||
|
|
63c5c5e6b1 | ||
|
|
d39fa1aa9d | ||
|
|
52aae9ed96 | ||
|
|
f72b7d3361 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=10.0
|
||||
AVERAGE_THRESHOLD=9.93
|
||||
AVERAGE_THRESHOLD=9.95
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -195,6 +195,11 @@ jobs:
|
||||
|
||||
linux-build:
|
||||
name: Linux build verification
|
||||
# Keep the normal push CI lane under the 10-minute target. The release
|
||||
# workflows already perform the full Linux/AppImage build after main
|
||||
# pushes, so this slower compatibility check stays available for PRs and
|
||||
# manual diagnostics without blocking every direct push.
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
@@ -209,6 +214,7 @@ jobs:
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
libfuse2 \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
build-essential \
|
||||
|
||||
12
.github/workflows/release-stable.yml
vendored
12
.github/workflows/release-stable.yml
vendored
@@ -283,6 +283,8 @@ jobs:
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
fcitx5-frontend-gtk3 \
|
||||
libfuse2 \
|
||||
librsvg2-dev \
|
||||
curl \
|
||||
wget \
|
||||
@@ -333,6 +335,7 @@ jobs:
|
||||
|
||||
- name: Build Tauri app (Linux bundles)
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
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 }}
|
||||
@@ -346,8 +349,11 @@ jobs:
|
||||
- name: Validate Linux bundles
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
appimages=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
)
|
||||
installers=(
|
||||
"${appimages[@]}"
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
)
|
||||
@@ -356,6 +362,10 @@ jobs:
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
)
|
||||
if [ ${#appimages[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
|
||||
exit 1
|
||||
|
||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -339,6 +339,8 @@ jobs:
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
fcitx5-frontend-gtk3 \
|
||||
libfuse2 \
|
||||
librsvg2-dev \
|
||||
curl \
|
||||
wget \
|
||||
@@ -389,6 +391,7 @@ jobs:
|
||||
|
||||
- name: Build Tauri app (Linux bundles)
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
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 }}
|
||||
@@ -402,8 +405,11 @@ jobs:
|
||||
- name: Validate Linux bundles
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
appimages=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
)
|
||||
installers=(
|
||||
"${appimages[@]}"
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
)
|
||||
@@ -412,6 +418,10 @@ jobs:
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
)
|
||||
if [ ${#appimages[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
|
||||
exit 1
|
||||
|
||||
@@ -152,41 +152,44 @@ if [ "$APP_CHANGED" = false ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 0. TypeScript + Vite build ──────────────────────────────────────────
|
||||
# ── 0. Frontend lint ───────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "📦 [0/5] TypeScript + Vite build..."
|
||||
pnpm exec tsc --noEmit
|
||||
# TypeScript is checked explicitly above; run Vite directly here to avoid
|
||||
# paying for the package build script's duplicate `tsc -b` pass.
|
||||
pnpm exec vite build
|
||||
echo "🔎 [0/6] Frontend lint..."
|
||||
pnpm lint
|
||||
echo " ✅ Lint OK"
|
||||
|
||||
# ── 1. TypeScript + Vite build ──────────────────────────────────────────
|
||||
echo ""
|
||||
echo "📦 [1/6] TypeScript + Vite build..."
|
||||
pnpm build
|
||||
echo " ✅ Build OK"
|
||||
|
||||
# ── 1. Frontend coverage (≥70%) — includes all unit tests ───────────────
|
||||
# ── 2. Frontend coverage (≥70%) — includes all unit tests ───────────────
|
||||
echo ""
|
||||
echo "📊 [1/5] Frontend tests + coverage (≥70%)..."
|
||||
echo "📊 [2/6] Frontend tests + coverage (≥70%)..."
|
||||
pnpm test:coverage --silent
|
||||
echo " ✅ Frontend coverage OK"
|
||||
|
||||
# ── 2. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
|
||||
# ── 3. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
|
||||
echo ""
|
||||
if [ "$RUST_CHANGED" = true ]; then
|
||||
echo "🔧 [2/5] Clippy + rustfmt..."
|
||||
echo "🔧 [3/6] Clippy + rustfmt..."
|
||||
cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
|
||||
cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
|
||||
echo " ✅ Rust lint OK"
|
||||
else
|
||||
echo "⏭️ [2/5] Rust lint — skipped (no src-tauri/ changes)"
|
||||
echo "⏭️ [3/6] Rust lint — skipped (no src-tauri/ changes)"
|
||||
fi
|
||||
|
||||
# ── 3. Rust coverage (≥85% lines) ──────────────────────────────────────
|
||||
# ── 4. Rust coverage (≥85% lines) ──────────────────────────────────────
|
||||
echo ""
|
||||
if [ "$RUST_CHANGED" = true ]; then
|
||||
LLVM_COV_FLAGS="--no-clean"
|
||||
if [ "${LAPUTA_FULL_COVERAGE:-0}" = "1" ]; then
|
||||
LLVM_COV_FLAGS=""
|
||||
echo "🦀 [3/5] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
|
||||
echo "🦀 [4/6] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
|
||||
else
|
||||
echo "🦀 [3/5] Rust coverage (≥85%, incremental)..."
|
||||
echo "🦀 [4/6] Rust coverage (≥85%, incremental)..."
|
||||
fi
|
||||
# Unset GIT_DIR so git tests create isolated repos without inheriting hook context
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
|
||||
@@ -199,24 +202,24 @@ if [ "$RUST_CHANGED" = true ]; then
|
||||
-- --test-threads=1
|
||||
echo " ✅ Rust coverage OK"
|
||||
else
|
||||
echo "⏭️ [3/5] Rust coverage — skipped (no src-tauri/ changes)"
|
||||
echo "⏭️ [4/6] Rust coverage — skipped (no src-tauri/ changes)"
|
||||
fi
|
||||
|
||||
# ── 4. Playwright core smoke lane (if any exist) ──────────────────────
|
||||
# ── 5. Playwright core smoke lane (if any exist) ──────────────────────
|
||||
echo ""
|
||||
SMOKE_FILES=$(find tests/smoke tests/integration -name '*.spec.ts' 2>/dev/null | head -1)
|
||||
if [ -n "$SMOKE_FILES" ]; then
|
||||
echo "🎭 [4/5] Playwright core smoke tests..."
|
||||
echo "🎭 [5/6] Playwright core smoke tests..."
|
||||
if ! pnpm playwright:smoke; then
|
||||
echo " ❌ Core smoke tests FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo " ✅ Core smoke tests OK"
|
||||
else
|
||||
echo "⏭️ [4/5] Playwright core smoke tests — skipped (no tests/**/*.spec.ts)"
|
||||
echo "⏭️ [5/6] Playwright core smoke tests — skipped (no tests/**/*.spec.ts)"
|
||||
fi
|
||||
|
||||
# ── 5. CodeScene code health gate (ratchet) ──────────────────────────────
|
||||
# ── 6. CodeScene code health gate (ratchet) ──────────────────────────────
|
||||
# Thresholds live in .codescene-thresholds and only ever go UP (ratchet).
|
||||
# If remote scores improved, the hook updates the file and stops so the new
|
||||
# floor is committed with normal verified hooks before the next push.
|
||||
@@ -232,7 +235,7 @@ if [ -f "$THRESHOLDS_FILE" ]; then
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏥 [5/5] CodeScene code health (Hotspot ≥${HOTSPOT_MIN} + Average ≥${AVERAGE_MIN})..."
|
||||
echo "🏥 [6/6] CodeScene code health (Hotspot ≥${HOTSPOT_MIN} + Average ≥${AVERAGE_MIN})..."
|
||||
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
|
||||
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping"
|
||||
else
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
"iconLibrary": "phosphor"
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ Git is a per-vault capability, not a prerequisite for the document model. A vaul
|
||||
| Git-backed | The vault path contains a Git repository | History, changes, commits, sync, conflict resolution, remotes, AutoGit, and auto-sync are available according to remote/config state |
|
||||
| Non-git | The vault path is a plain folder | Markdown scanning, editing, search, and navigation work; Git-dependent status-bar controls and command-palette entries are replaced by `Git disabled` + `Initialize Git for Current Vault` |
|
||||
|
||||
Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. Features that depend on Git must check this capability instead of assuming every vault has `.git`.
|
||||
Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. The setup dialog supports "not now" for a one-time dismissal and "never for this vault" for a local per-vault opt-out from future automatic prompts. Features that depend on Git must check both the vault capability and the installation-local `git_enabled` setting instead of assuming every vault has `.git` or that Git chrome is globally visible.
|
||||
|
||||
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.
|
||||
|
||||
@@ -97,7 +97,7 @@ classDiagram
|
||||
+WorkspaceIdentity? workspace
|
||||
+Boolean trashed ⚠ legacy
|
||||
+Number? trashedAt ⚠ legacy
|
||||
+Record~string,string~ properties
|
||||
+Record~string,VaultPropertyValue~ properties
|
||||
}
|
||||
|
||||
class TypeDocument {
|
||||
@@ -149,7 +149,7 @@ interface VaultEntry {
|
||||
archived: boolean // Archived flag
|
||||
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)
|
||||
properties: Record<string, VaultPropertyValue> // Scalar and scalar-array custom properties
|
||||
fileKind?: 'markdown' | 'text' | 'binary' // Controls editor/raw/preview behavior
|
||||
}
|
||||
```
|
||||
@@ -189,7 +189,7 @@ Git-facing renderer code must pass an explicit repository path instead of assumi
|
||||
| `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics |
|
||||
| `binary` | Images, audio, video, PDFs, archives, other non-text files | Stays a normal vault file; previewable media and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
|
||||
|
||||
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>`, supported audio/video render through native HTML media controls, and supported PDFs render through the webview's PDF object renderer, all backed by Tauri asset URLs. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
|
||||
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>`, supported audio/video render through native HTML media controls, and supported PDFs render through the webview's PDF object renderer, all backed by Tauri asset URLs. On Linux AppImage builds, `should_use_external_media_preview` can disable in-webview audio/video rendering so the same file blocks show filename/external-open fallback controls instead of triggering unstable WebKitGTK media playback. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
|
||||
|
||||
### Note Content Freshness
|
||||
|
||||
@@ -250,7 +250,7 @@ Each entity type can have a corresponding **type document**: any markdown note w
|
||||
|
||||
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[project]]`. This makes the type navigable from the Inspector panel while keeping location as an implementation detail.
|
||||
|
||||
**Instance schema/defaults**: Custom scalar properties and relationship fields on a type document define the expected shape for notes of that type. Existing instances do not get mutated when a type changes; the Inspector enriches their real frontmatter with gray placeholders for missing type-defined properties/relationships. Valued type fields are copied into frontmatter only when Tolaria creates a new instance of that type. Blank type fields stay as placeholders.
|
||||
**Instance schema/defaults**: Custom scalar/scalar-array properties and relationship fields on a type document define the expected shape for notes of that type. Existing instances do not get mutated when a type changes; the Inspector enriches their real frontmatter with gray placeholders for missing type-defined properties/relationships. Valued type fields are copied into frontmatter only when Tolaria creates a new instance of that type. Blank type fields stay as placeholders.
|
||||
|
||||
**UI behavior**:
|
||||
- Clicking a section group header pins the type document at the top of the NoteList if it exists
|
||||
@@ -282,6 +282,8 @@ Supported value types (defined in `src-tauri/src/frontmatter/yaml.rs` as `Frontm
|
||||
- **List**: Multi-line ` - item` or inline `[item1, item2]`
|
||||
- **Null**: `owner:` (empty value)
|
||||
|
||||
Custom frontmatter fields with scalar values are exposed through `VaultEntry.properties`. Custom fields with scalar arrays are also exposed there, unless any array value contains a wikilink; wikilink-bearing fields belong to `VaultEntry.relationships`. Single-item scalar arrays continue to normalize to their scalar value for compatibility, while multi-item scalar arrays remain arrays so saved view filters can match exact elements.
|
||||
|
||||
### Custom Relationships
|
||||
|
||||
The Rust parser scans all frontmatter keys for fields containing `[[wikilinks]]`. Any non-standard field with wikilink values is captured in the `relationships` HashMap:
|
||||
@@ -353,7 +355,7 @@ type SidebarSelection =
|
||||
|
||||
### Saved Views
|
||||
|
||||
Saved Views live as YAML files under `views/`. Their definition includes user-visible fields (`name`, `icon`, `color`), note-list preferences (`sort`, `listPropertiesDisplay`), filters, and an optional top-level `order` number. The `sort` value accepts built-in sort forms such as `"modified:desc"` and custom-property forms such as `"property:Priority:asc"` or bare `"Priority:asc"`; the renderer keeps configured custom-property sorts visible even when the current result set has no populated values for that property. The `order` value is stored directly in the YAML document, not in Markdown frontmatter, and lower values render earlier in every saved-View list. Views without an explicit order sort after ordered views by filename for stable fallback behavior.
|
||||
Saved Views live as YAML files under `views/`. Their definition includes user-visible fields (`name`, `icon`, `color`), note-list preferences (`sort`, `listPropertiesDisplay`), filters, and an optional top-level `order` number. The `sort` value accepts built-in sort forms such as `"modified:desc"` and custom-property forms such as `"property:Priority:asc"` or bare `"Priority:asc"`; the renderer keeps configured custom-property sorts visible even when the current result set has no populated values for that property. Filter conditions on scalar-array custom properties, such as `tags: [blues, chicago]`, evaluate `contains`, `any_of`, and related set operators against exact array elements rather than substrings. The `order` value is stored directly in the YAML document, not in Markdown frontmatter, and lower values render earlier in every saved-View list. Views without an explicit order sort after ordered views by filename for stable fallback behavior.
|
||||
|
||||
In a mounted-workspace graph, each loaded `ViewFile` carries optional renderer-owned `rootPath` and `workspace` provenance. `SidebarSelection.kind === 'view'` can include that `rootPath`, and view identity is `(rootPath, filename)` rather than filename alone. This lets two vaults both expose `views/focus.yml` without colliding in sidebar selection, note-list filtering, counts, sort/column persistence, edit, or delete flows. A saved View with `rootPath` filters only entries from its own workspace and persists changes through `save_view_cmd` / `delete_view_cmd` against that source vault.
|
||||
|
||||
@@ -374,9 +376,9 @@ The renderer uses `viewOrdering` helpers to convert drag or command-palette move
|
||||
|
||||
## Command Surface
|
||||
|
||||
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, register the Windows main-window menu event bridge, and toggle state-dependent menu items from manifest groups.
|
||||
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, custom titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, and toggle state-dependent menu items from manifest groups.
|
||||
|
||||
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the Linux fallback menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
|
||||
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the custom desktop titlebar menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
|
||||
|
||||
## File System Integration
|
||||
|
||||
@@ -411,7 +413,7 @@ Renderer attachment paths are normalized through `src/utils/vaultAttachments.ts`
|
||||
|
||||
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary.
|
||||
|
||||
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same packaged `mcp-server/` resolver as registration and app-managed AI agents, including Windows executable-adjacent installs under `%LOCALAPPDATA%\Tolaria`, so the copied snippet remains scoped to the active vault without writing third-party config files. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
|
||||
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 the active mounted workspace set in `VAULT_PATHS`, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints accept explicit `VAULT_PATH`/`VAULT_PATHS` for app-owned or legacy launches; durable external registrations omit vault env and resolve the current mounted workspace set from Tolaria's `vaults.json` at tool-call time. Manual MCP config export uses the same packaged `mcp-server/` resolver as registration and app-managed AI agents, including Windows executable-adjacent installs under `%LOCALAPPDATA%\Tolaria`, so the copied snippet stays durable across active-workspace changes without writing third-party config files. Vault context checks each active workspace root for `AGENTS.md` and returns those instructions alongside note counts, folders, and recent notes. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
|
||||
|
||||
### Vault Caching
|
||||
|
||||
@@ -504,15 +506,16 @@ interface PulseCommit {
|
||||
|
||||
### Auto-Sync
|
||||
|
||||
`useAutoSync` hook handles automatic git sync:
|
||||
`useAutoSync` hook handles automatic git sync across every active Git repository:
|
||||
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
|
||||
- Pulls on interval, pushes after commits
|
||||
- Awaits the post-pull vault refresh so toasts land after note-list state is fresh
|
||||
- Pulls the active repository set concurrently on launch, focus, interval, and manual sync
|
||||
- Pushes the active repository set during divergence recovery
|
||||
- Awaits the post-pull vault refreshes so toasts land after note-list state is fresh
|
||||
- Reopens the clean active tab from disk only when the pull changed that active note, so unrelated updates do not remount the editor
|
||||
- Detects merge conflicts → opens `ConflictResolverModal`
|
||||
- Tracks remote status (branch, ahead/behind via `git_remote_status`)
|
||||
- Tracks aggregate remote status (ahead/behind via `git_remote_status`)
|
||||
- Handles push rejection (divergence) → sets `pull_required` status
|
||||
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
|
||||
- `pullAndPush()`: pulls then auto-pushes each active repository for divergence recovery
|
||||
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
|
||||
|
||||
### External Vault Refresh
|
||||
@@ -545,7 +548,7 @@ External vault mutations are any disk writes Tolaria did not just perform throug
|
||||
- **No remote indicator**: Neutral chip in the bottom bar when `GitRemoteStatus.hasRemote === false`
|
||||
- **Pulse view**: Activity feed when Pulse filter is selected
|
||||
- **Pull command**: Cmd+K → "Pull from Remote", also in Vault menu
|
||||
- **Git status popup**: Click sync badge → shows branch, ahead/behind, Pull button
|
||||
- **Git status popup**: Click sync badge → shows aggregate ahead/behind and a Pull button for the active repository set
|
||||
- **Conflict banner**: Inline banner in editor with Keep mine / Keep theirs for conflicted notes
|
||||
|
||||
## BlockNote Customization
|
||||
@@ -632,7 +635,7 @@ 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["preProcessDurableEditorMarkdown(body)\nmermaid/tldraw fences → tokens"]
|
||||
B --> C["preProcessDurableEditorMarkdown(body)\nmermaid/tldraw fences + file links → tokens"]
|
||||
C --> D["preProcessWikilinks(body)\n[[target]] → ‹token›"]
|
||||
D --> E["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
|
||||
E --> F["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
|
||||
@@ -643,7 +646,7 @@ flowchart LR
|
||||
style H fill:#d4edda,stroke:#28a745,color:#000
|
||||
```
|
||||
|
||||
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math, Mermaid, and tldraw placeholder tokens use ASCII sentinels with URI-encoded payloads.
|
||||
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math, Mermaid, tldraw, and standalone file-attachment link placeholders use ASCII sentinels with URI-encoded payloads.
|
||||
|
||||
### BlockNote-to-Markdown Pipeline (Save)
|
||||
|
||||
@@ -658,7 +661,7 @@ flowchart LR
|
||||
style E fill:#d4edda,stroke:#28a745,color:#000
|
||||
```
|
||||
|
||||
Rich-editor change events are coalesced before this serialization runs. `useEditorTabSwap` keeps the latest BlockNote state in the editor, schedules one Markdown serialization for a short idle window, and exposes an explicit flush hook for save, note switch, raw-mode entry, and destructive note actions. `src/utils/richEditorMarkdown.ts` is the shared BlockNote-to-Markdown owner for autosave/tab-swap and raw-mode entry, so wikilink restoration, durable schema-node serialization, frontmatter preservation, and portable attachment paths cannot drift between editor modes. This keeps long notes from paying full-document Markdown serialization on every keystroke while preserving the disk-first save path.
|
||||
Rich-editor change events are coalesced before this serialization runs. `useEditorTabSwap` keeps the latest BlockNote state in the editor, schedules one Markdown serialization for a short idle window, and exposes an explicit flush hook for save, note switch, raw-mode entry, and destructive note actions. `src/utils/richEditorMarkdown.ts` is the shared BlockNote-to-Markdown owner for autosave/tab-swap and raw-mode entry, so wikilink restoration, durable schema-node serialization, frontmatter preservation, file-attachment block round-tripping, and portable attachment paths cannot drift between editor modes. This keeps long notes from paying full-document Markdown serialization on every keystroke while preserving the disk-first save path.
|
||||
|
||||
Autosave then waits for a 1.5s idle window before invoking `save_note_content`. If an older save resolves after the user has already typed newer content, the older save is treated as stale and cannot clear the newer pending buffer or repaint tab state over it; the latest pending content remains scheduled for its own save.
|
||||
|
||||
@@ -774,7 +777,7 @@ Mounted workspaces are loaded together by `useVaultLoader` for note-list, quick-
|
||||
|
||||
Per-vault settings stored locally and scoped by vault path:
|
||||
- Managed by `useVaultConfig` hook and `vaultConfigStore`
|
||||
- Settings: zoom, view mode, editor mode, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`)
|
||||
- Settings: zoom, view mode, editor mode, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, Git setup prompt preference, AI agent permission mode (`safe` / `power_user`)
|
||||
- Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
@@ -811,7 +814,7 @@ Vault guidance is intentionally short and vault-specific. General Tolaria produc
|
||||
`useAiAgentsOnboarding(enabled)` adds a separate first-launch agent step:
|
||||
- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key)
|
||||
- Only shows after vault onboarding has already resolved to a ready state
|
||||
- Uses `get_ai_agents_status`, whose backend treats the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
|
||||
- Uses `get_ai_agents_status`, whose backend checks Claude Code, Codex, OpenCode, Pi, Gemini, and Kiro by treating the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
|
||||
- Persists dismissal locally once the user continues
|
||||
|
||||
### Remote Git Operations
|
||||
@@ -820,8 +823,9 @@ Tolaria delegates remote auth to the user's system git setup:
|
||||
- `CloneVaultModal` captures a remote URL and local destination
|
||||
- `clone_git_repo` and `create_getting_started_vault` both run system git clone work in blocking Tokio tasks so clone UIs stay responsive
|
||||
- On macOS, system-git commands prefer the user's login-shell `git` and `PATH`, and `git_add_remote` preflights HTTPS remotes through `git credential fill` so Keychain can prompt/grant access before the first fetch or push
|
||||
- On Linux AppImage launches, every system-git command and MCP Node subprocess removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning, so helpers like `git-remote-https` and system `node` bind against the host library stack instead of Tolaria's bundled WebKit/AppImage libraries
|
||||
- On Linux AppImage Wayland launches, startup environment safeguards also set `GTK_IM_MODULE=fcitx` when common input-method variables already indicate fcitx and the user has not explicitly chosen a GTK IM module, allowing WebKitGTK editor input to reach fcitx5 on compositors such as niri without overriding deliberate `GTK_IM_MODULE` choices.
|
||||
- On Linux AppImage launches, every system-git command and MCP runtime subprocess (Node.js or Bun) removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning, so helpers like `git-remote-https` and the system MCP runtime bind against the host library stack instead of Tolaria's bundled WebKit/AppImage libraries
|
||||
- On native Linux Wayland launches and Linux AppImage launches, startup environment safeguards set `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` unless the user already provided either variable, keeping WebKitGTK rendering crashes out of the app startup path while leaving native X11 launches unchanged.
|
||||
- On Linux AppImage launches, release packaging bundles the GTK3 fcitx immodule into the AppImage and startup environment safeguards write a cache-local `GTK_IM_MODULE_FILE` that points GTK at the mounted module whenever fcitx is configured. If the user has not explicitly chosen a GTK IM module, Tolaria also sets `GTK_IM_MODULE=fcitx`, allowing WebKitGTK editor input to reach fcitx5 on both Wayland and X11 fallback launches without relying on host GTK module cache paths.
|
||||
- `git_add_remote` uses the same system git path and refuses remotes whose history is unrelated or ahead of the local vault
|
||||
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors, and clone commands fail fast when git wants interactive terminal input
|
||||
- No provider-specific token or username is stored in app settings
|
||||
@@ -847,7 +851,8 @@ interface Settings {
|
||||
note_width_mode: 'normal' | 'wide' | null
|
||||
sidebar_type_pluralization_enabled: boolean | null // null = default true
|
||||
ai_features_enabled: boolean | null // null = default true
|
||||
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null
|
||||
git_enabled: boolean | null // null = default true
|
||||
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | 'kiro' | null
|
||||
default_ai_target: string | null // "agent:codex" or "model:<provider>/<model>"
|
||||
ai_model_providers: AiModelProvider[] | null
|
||||
hide_gitignored_files: boolean | null // null = default true
|
||||
@@ -857,7 +862,7 @@ interface Settings {
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `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. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `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. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
|
||||
## Telemetry
|
||||
|
||||
@@ -905,6 +910,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. The docs/release Pages job reads the stable manifest from the latest stable release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so the public updater JSON changes as part of the release workflow. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, Linux x86_64 `.deb` / AppImage artifacts, and a static public download page that starts the selected installer without replacing the page with a blank download navigation. The Pages job reads the alpha manifest from the latest alpha release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so stable and alpha manifests stay fresh. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
|
||||
- **`.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. The Linux job uses Tauri's stock linuxdeploy AppImage output plugin and validates that installer and updater-signature artifacts exist before upload. The docs/release Pages job reads the stable manifest from the latest stable release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so the public updater JSON changes as part of the release workflow. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, Linux x86_64 `.deb` / `.rpm` / AppImage artifacts, and a static public download page that starts the selected installer without replacing the page with a blank download navigation. Linux visitors default to the AppImage target while the page exposes RPM as a manual Linux package option when the stable release includes one. The Linux job uses the same stock Tauri/linuxdeploy AppImage packaging and artifact validation as alpha releases. The Pages job reads the alpha manifest from the latest alpha release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so stable and alpha manifests stay fresh. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
|
||||
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.
|
||||
|
||||
@@ -26,7 +26,9 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
|
||||
| Property display order | Window size / position |
|
||||
| Per-note `_width` rich-editor width override | Default rich-editor note width |
|
||||
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
|
||||
| N/A | Whether this installation shows Git features |
|
||||
| Per-vault All Notes note-list column overrides | All Notes PDF/image/unsupported file visibility |
|
||||
| N/A | Per-vault Git setup prompt opt-out |
|
||||
| Type `_sidebar_label` overrides | Whether this installation auto-pluralizes type labels |
|
||||
| N/A | Registered workspace labels, aliases, mount state, and default new-note destination |
|
||||
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
|
||||
@@ -135,12 +137,12 @@ The note list opportunistically preloads visible and adjacent markdown/text entr
|
||||
| Raw editor | CodeMirror 6 | - |
|
||||
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
|
||||
| UI primitives | Radix UI + shadcn/ui | - |
|
||||
| Icons | Phosphor Icons + Lucide | - |
|
||||
| Icons | Phosphor Icons | - |
|
||||
| Build | Vite | 7.3.1 |
|
||||
| Backend language | Rust (edition 2021) | 1.77.2 |
|
||||
| Frontmatter parsing | gray_matter | 0.2 |
|
||||
| Filesystem watcher | notify | 6.1 |
|
||||
| AI (agent panel) | CLI agent adapters (Claude Code + Codex + OpenCode + Pi + Gemini) | - |
|
||||
| AI (agent panel) | CLI agent adapters (Claude Code + Codex + OpenCode + Pi + Gemini + Kiro) | - |
|
||||
| Search | Keyword (walkdir-based file scan) | - |
|
||||
| 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 |
|
||||
@@ -179,7 +181,7 @@ flowchart TD
|
||||
end
|
||||
|
||||
subgraph EXT["External Services"]
|
||||
CCLI["Claude / Codex / OpenCode / Pi / Gemini CLI\n(agent subprocesses)"]
|
||||
CCLI["Claude / Codex / OpenCode / Pi / Gemini / Kiro 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.)"]
|
||||
@@ -222,19 +224,19 @@ flowchart TD
|
||||
|
||||
- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types for single-vault lists: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. In multiple-vault mode, saved View rows are keyed by source vault plus filename so duplicate filenames do not collide, and edits/deletes route to the owning vault. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root.
|
||||
- **Note List / Pulse View** (220-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. Inbox organization auto-advance is coordinated by `useInboxOrganizeAdvance`, which only opens the next visible Inbox note when the organized note is still the active requested tab after the write finishes. Folder-backed lists also show non-Markdown files: previewable media and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `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.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `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.
|
||||
- **Right side panels** (200-500px or hidden): Properties, Table of Contents, and AI Agent are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation; AI Agent keeps the selected CLI/API target controller mounted for tool execution and chat state. The breadcrumb bar toggles Table of Contents, AI, and Properties actions, and opening one replaces the others. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties 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. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`.
|
||||
|
||||
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds the current sidebar / note-list / expanded-inspector widths on top with minimum floors, and calls the native `update_current_window_min_size` command whenever view mode, inspector visibility, or restored pane widths change. That same native command can grow the current window back out when a wider pane combination is restored, but Windows keeps grow-to-fit disabled so opening or closing the Properties panel does not unmaximize or reposition the main window. Note windows skip this path and keep their dedicated 800×700 initial sizing.
|
||||
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds the current sidebar / note-list / expanded-inspector widths on top with minimum floors, and calls the native `update_current_window_min_size` command whenever view mode, inspector visibility, or restored pane widths change. That same native command can grow the current window back out when a wider pane combination is restored, but Windows keeps grow-to-fit disabled and skips min-size mutation while fullscreen or maximized so navigation/sidebar interactions do not unfullscreen or reposition the main window. Note windows skip this path and keep their dedicated 800×700 initial sizing.
|
||||
|
||||
The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline.
|
||||
|
||||
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell.
|
||||
|
||||
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, registers the macOS Window submenu with Tauri's reserved `WINDOW_SUBMENU_ID` so NSApp can add system window-management and window-list items, registers a window-scoped menu event handler on Windows where Tauri delivers menu clicks through the main `WebviewWindow`, and cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback.
|
||||
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, and on Wayland sessions it re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. If an AppImage Wayland session advertises fcitx through common input-method environment hints and the user has not already chosen `GTK_IM_MODULE`, Tolaria sets `GTK_IM_MODULE=fcitx` before WebKit starts so GTK/WebKit input contexts reach fcitx5 on compositors that do not reliably provide the Wayland input method frontend. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, the fcitx GTK fallback preserves Chinese/Japanese/Korean IME composition for affected AppImage Wayland users, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
|
||||
Linux and Windows use custom React-rendered window chrome instead of the native Tauri menu bar. `setup_custom_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu is macOS-only so Services/Hide/Quit and the reserved `WINDOW_SUBMENU_ID` keep behaving like normal NSApp menu items, while cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback from the renderer menu.
|
||||
On Linux, `run()` applies WebKitGTK startup safeguards before Tauri creates the webview. Native Wayland launches and AppImage launches inject `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, covering compositor-specific WebKit crashes without changing native X11 launches. AppImage launches keep the additional AppImage-only safeguards: on Wayland sessions Tolaria re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. Runtime startup writes a mount-path-specific `GTK_IM_MODULE_FILE` cache when fcitx is configured via `GTK_IM_MODULE=fcitx` or common fcitx environment hints; release packaging currently uses Tauri's stock linuxdeploy AppImage output plugin instead of Tolaria's experimental output-plugin shim. If the user has not already chosen `GTK_IM_MODULE`, Tolaria sets `GTK_IM_MODULE=fcitx` before WebKit starts. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep WebViews from blanking or crashing after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
|
||||
|
||||
## Multi-Window (Note Windows)
|
||||
|
||||
@@ -263,8 +265,8 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts` + `aiTargets.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-target selection, bundled-docs prompt injection, and the per-vault Safe / Power User permission mode shown in the panel header for coding agents
|
||||
2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
|
||||
3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents
|
||||
4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. Codex, OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. Pi seeds its transient agent directory from the user's Pi agent directory before merging Tolaria MCP, so app-managed runs keep standalone Pi provider/auth settings. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
|
||||
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` after copying and merging the user's Pi agent config, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
|
||||
4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous permission-bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. Kiro runs through `kiro-cli chat --no-interactive --trust-all-tools`, streams line-oriented stdout, drains stderr concurrently, and writes prompt content through stdin to avoid OS argument length limits. Codex, OpenCode, Pi, Gemini, and Kiro all launch from the active vault cwd with transient MCP config. Pi seeds its transient agent directory from the user's Pi agent directory before merging Tolaria MCP, so app-managed runs keep standalone Pi provider/auth settings. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
|
||||
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` after copying and merging the user's Pi agent config, Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`, and Kiro receives it through `.kiro/settings/mcp.json` in the active vault.
|
||||
|
||||
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. App-managed CLI spawns also expand the active vault path before using it as the subprocess working directory, then extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path.
|
||||
|
||||
@@ -283,11 +285,11 @@ sequenceDiagram
|
||||
U->>FE: sendMessage(text, references)
|
||||
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
|
||||
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath, permissionMode})
|
||||
R->>R: pick adapter for claude_code, codex, opencode, or pi
|
||||
R->>R: pick adapter for claude_code, codex, opencode, pi, gemini, or kiro
|
||||
R->>C: spawn agent with MCP-enabled config
|
||||
|
||||
loop Normalized stream
|
||||
C-->>R: Claude NDJSON, Codex JSONL, OpenCode JSON, Pi JSON, or Gemini JSONL events
|
||||
C-->>R: Claude NDJSON, Codex JSONL, OpenCode JSON, Pi JSON, Gemini JSONL, or Kiro text events
|
||||
R-->>FE: emit("ai-agent-stream", event)
|
||||
alt TextDelta
|
||||
FE->>FE: accumulate response (revealed on Done)
|
||||
@@ -341,7 +343,7 @@ Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existi
|
||||
|
||||
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Gemini CLI, Cursor, or any MCP-compatible client).
|
||||
|
||||
### Tool Surface (14 tools)
|
||||
### Tool Surface
|
||||
|
||||
| Tool | Params | Description |
|
||||
|------|--------|-------------|
|
||||
@@ -349,12 +351,13 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|
||||
| `read_note` | `path` | Read note content (alias for `open_note`) |
|
||||
| `create_note` | `path, title, [type]` | Create new note with title and optional type frontmatter |
|
||||
| `search_notes` | `query, [limit]` | Search notes by title or content substring |
|
||||
| `list_vaults` | — | List active mounted vaults and whether each has root `AGENTS.md` instructions |
|
||||
| `append_to_note` | `path, text` | Append text to end of existing note |
|
||||
| `edit_note_frontmatter` | `path, patch` | Merge key-value patch into YAML frontmatter |
|
||||
| `delete_note` | `path` | Delete a note file from the vault |
|
||||
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
|
||||
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
|
||||
| `vault_context` | — | Get vault summary: entity types + 20 recent notes + configFiles |
|
||||
| `vault_context` / `get_vault_context` | `[vaultPath]` | Get mounted-vault summary: entity types, folders, recent notes, and root `AGENTS.md` instructions |
|
||||
| `ui_open_note` | `path` | Open a note in the Tolaria UI editor |
|
||||
| `ui_open_tab` | `path` | Open a note in a new UI tab |
|
||||
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
|
||||
@@ -374,27 +377,28 @@ Tolaria can register itself as an MCP server in:
|
||||
- `~/.gemini/settings.json` (Gemini CLI)
|
||||
- `~/.cursor/mcp.json` (Cursor)
|
||||
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
|
||||
- `~/.config/opencode/opencode.json` (OpenCode, using its `mcp` config key)
|
||||
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini/OpenCode settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria resolves an MCP runtime (Node.js 18+ preferred, Bun 1+ as fallback) before writing config so external clients are not left pointing at a missing binary, writes a vault-neutral `type: "stdio"` entry for standard MCP clients, writes OpenCode's vault-neutral `type: "local"` entry, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Durable external MCP processes resolve active workspaces at tool-call time: explicit `VAULT_PATH`/`VAULT_PATHS` env still wins for app-owned and legacy launches, otherwise the MCP server reads Tolaria's `vaults.json`, uses `active_vault` first, and includes every workspace not marked `mounted: false`. Vault context checks each active workspace root for `AGENTS.md` and includes those instructions in the returned context. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. Linux AppImage startup extracts the bundled `mcp-server/` to `~/.local/share/tolaria/mcp-server/` with a `.tolaria-version` marker, so durable external registrations use a stable path instead of the changing AppImage mount point. The `useMcpStatus` hook tracks whether Tolaria's durable MCP entry is connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the runtime process instead of keeping it alive in the background.
|
||||
|
||||
### Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph MCP["MCP Server (Node.js) — selected-vault scoped"]
|
||||
subgraph MCP["MCP Server (Node.js or Bun) — mounted-workspace scoped"]
|
||||
IDX["index.js"]
|
||||
VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"]
|
||||
WSB["ws-bridge.js"]
|
||||
|
||||
IDX -->|"stdio transport"| STDIO["Claude Code / Cursor"]
|
||||
IDX -->|"stdio transport"| STDIO["Claude Code / Cursor / Gemini / OpenCode"]
|
||||
IDX --> VAULT
|
||||
IDX --> WSB
|
||||
WSB -->|"port 9710 — tool bridge"| AI["AI Clients\n(Claude Code, external)"]
|
||||
WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"]
|
||||
end
|
||||
|
||||
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"]
|
||||
TAURI["Tauri bridge lifecycle"] -->|"start/stop/restart with VAULT_PATHS"| 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\n~/.config/opencode/opencode.json"]
|
||||
```
|
||||
|
||||
### WebSocket Bridge
|
||||
@@ -420,11 +424,12 @@ flowchart LR
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
|
||||
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with `VAULT_PATH`/`VAULT_PATHS` env |
|
||||
| `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes |
|
||||
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Gemini CLI, Cursor, and generic MCP configs on user request |
|
||||
| `mcp_config_snippet(vault_path)` | Builds the exact `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files |
|
||||
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, and generic MCP configs |
|
||||
| `extract_mcp_server_to_stable_dir(app_version)` | On Linux AppImage launches, copies bundled MCP files to `~/.local/share/tolaria/mcp-server/` with version-gated replacement so external clients can keep a stable `index.js` path |
|
||||
| `register_mcp(vault_path)` | Resolves an MCP runtime (Node.js 18+ preferred, Bun 1+ fallback), resolves the packaged or stable extracted `mcp-server/`, and writes Tolaria's vault-neutral entry to Claude Code, Gemini CLI, Cursor, OpenCode, and generic MCP configs on user request |
|
||||
| `mcp_config_snippet(vault_path)` | Builds the exact vault-neutral `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files |
|
||||
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, OpenCode, and generic MCP configs |
|
||||
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
|
||||
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed plus waited on app exit via the `RunEvent::Exit` handler. The same desktop layer keeps Tauri asset protocol access limited to vault roots loaded during the current app session; command calls remain active-vault scoped for reads, writes, and external opens.
|
||||
@@ -446,7 +451,7 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
|
||||
|
||||
### Cache File
|
||||
|
||||
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is normalized through `vault/path_identity.rs` before hashing, so macOS `/tmp` aliases and separator variants share the same cache identity. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
|
||||
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is normalized through `vault/path_identity.rs` before hashing, so macOS `/tmp` aliases and separator variants share the same cache identity. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v14 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
|
||||
|
||||
`<vault>/.tolaria-rename-txn/` — hidden, scan-ignored staging directory for crash-safe note renames. Tolaria stores temporary backup files plus one manifest per in-flight rename here. On the next vault scan, unfinished transactions are recovered before entries are listed so users do not see a missing note or a visible duplicate after a crash.
|
||||
|
||||
@@ -509,6 +514,7 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
|
||||
- `inbox.noteListProperties`: Optional Inbox-only property chip override for the note list
|
||||
- `allNotes.noteListProperties`: Optional All Notes-only property chip override for the note list
|
||||
- `inbox.explicitOrganization`: When `false`, hide Inbox and the organized toggle so the vault behaves like a plain note collection
|
||||
- `git_setup_preference`: `"never"` when the user has opted out of future automatic Git setup prompts for that vault
|
||||
|
||||
### Getting Started Vault
|
||||
|
||||
@@ -519,11 +525,11 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
|
||||
|
||||
If the selected vault disappears after startup, `useVaultLoader` re-checks `check_vault_exists` when reloads or vault-derived surfaces fail. A confirmed missing path clears cached entries, folders, views, modified-file state, and prefetched note content, then `App` reuses the `vault-missing` `WelcomeScreen` state so note and view actions cannot keep targeting the stale active vault.
|
||||
|
||||
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 an opened folder is not yet a git repo, Tolaria can show a Git setup dialog with Initialize, Not now, and Never for this vault actions. The Never choice stores a local per-vault `git_setup_preference` so the automatic dialog does not return for that vault, while manual initialization remains reachable from Git commands when global Git features are enabled. Markdown scanning, note browsing, note editing, and search continue normally in plain folders. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git.
|
||||
|
||||
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, remote-connection, manual/automatic, and conflict-resolution commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
|
||||
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, OpenCode, Pi, and Gemini CLI are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
|
||||
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, OpenCode, Pi, Gemini, and Kiro CLI are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
|
||||
|
||||
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
|
||||
|
||||
@@ -573,7 +579,7 @@ sequenceDiagram
|
||||
participant MCP as MCP Server
|
||||
participant U as User
|
||||
|
||||
T->>T: apply Linux AppImage WebKit env/preload safeguards<br/>(AppImage only)
|
||||
T->>T: apply Linux WebKit env safeguards<br/>(Wayland/AppImage)
|
||||
T->>T: start background legacy vault housekeeping<br/>(does not block setup)
|
||||
T->>MCP: start background initial ws-bridge sync<br/>(if active vault exists)
|
||||
T->>A: App mounts
|
||||
@@ -654,7 +660,9 @@ flowchart TD
|
||||
|
||||
`useGitRemoteStatus` re-checks `git_remote_status` for the default repository, and `useCommitFlow` can resolve remote status for an explicit selected repository when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps that repository's flow local-only: the status bar shows a neutral `No remote` chip for the default repository, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
|
||||
|
||||
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.
|
||||
If the current vault is not a Git repository, Tolaria treats Git as unavailable instead of degraded. With global Git features enabled, the status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup unless the user has chosen not to be prompted automatically for that vault. 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 installation-local `git_enabled` setting is a broader visibility switch. When it is `false`, Tolaria hides Git status-bar entries and Git command-palette actions completely, disables AutoGit controls in Settings, and prevents background Git refresh/sync work even for repositories that are otherwise Git-backed. Settings remains the re-enable path.
|
||||
|
||||
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.
|
||||
|
||||
@@ -785,12 +793,12 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|---------|-------------|
|
||||
| `stream_claude_chat` | Claude CLI chat mode (streaming) |
|
||||
| `check_claude_cli` | Check if Claude CLI is available |
|
||||
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi + Gemini availability |
|
||||
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi + Gemini + Kiro availability |
|
||||
| `get_agent_docs_path` | Resolve the bundled local Tolaria docs folder used in AI-agent system prompts |
|
||||
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, Pi, or Gemini through the normalized agent event layer |
|
||||
| `register_mcp_tools` | Register MCP in Claude/Gemini/Cursor/generic config for the active vault |
|
||||
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/generic config |
|
||||
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Gemini/Cursor/generic config |
|
||||
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, Pi, Gemini, or Kiro through the normalized agent event layer |
|
||||
| `register_mcp_tools` | Register vault-neutral MCP in Claude/Gemini/Cursor/OpenCode/generic config |
|
||||
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/OpenCode/generic config |
|
||||
| `check_mcp_status` | Check whether Tolaria's durable MCP entry is registered in Claude/Gemini/Cursor/OpenCode/generic config |
|
||||
| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault |
|
||||
| `copy_text_to_clipboard` | Copy setup snippets through the native desktop clipboard command path |
|
||||
| `read_text_from_clipboard` | Read current desktop clipboard text for command-driven plain-text paste |
|
||||
@@ -862,8 +870,8 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `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, date display format, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility, All Notes file visibility) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences, AI permission mode | Vault-specific config |
|
||||
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, date display format, auto-sync interval, Git visibility, AutoGit thresholds, default AI agent, Gitignored-content visibility, All Notes file visibility) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences, Git setup prompt preference, AI permission mode | Vault-specific config |
|
||||
| `appCommandDispatcher` | Manifest-backed shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
|
||||
|
||||
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
|
||||
@@ -896,7 +904,7 @@ Shortcut routing is explicit:
|
||||
- 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+Shift+V` uses the same command path for "Paste without Formatting"; `plainTextPaste.ts` reads text through the native clipboard command in Tauri and inserts it through the active rich/raw editor target or the focused browser text control
|
||||
- `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 manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions; on Windows, `menu.rs` also listens to main-window menu events because Tauri attaches the native menu to the `WebviewWindow`
|
||||
- `menu.rs`, `useMenuEvents`, and the custom titlebar `LinuxMenuButton` emit the same manifest-derived 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:
|
||||
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`
|
||||
@@ -922,6 +930,11 @@ push to main
|
||||
→ build-windows job:
|
||||
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
|
||||
→ build-linux job:
|
||||
→ pnpm install, stamp version
|
||||
→ tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||
→ verify Linux installer and updater-signature artifacts exist
|
||||
→ upload .deb, .rpm, .AppImage, and signed Linux updater bundles
|
||||
→ release job:
|
||||
→ generate alpha-latest.json with darwin-aarch64, darwin-x86_64, Linux, and Windows updater URLs
|
||||
→ publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N
|
||||
@@ -944,8 +957,10 @@ push stable-vYYYY.M.D tag
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin
|
||||
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig and .dmg artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
|
||||
→ build-linux job:
|
||||
→ pnpm install, stamp version, tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
|
||||
→ upload .deb, .AppImage, and signed Linux updater bundles
|
||||
→ pnpm install, stamp version
|
||||
→ tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||
→ verify Linux installer and updater-signature artifacts exist
|
||||
→ upload .deb, .rpm, .AppImage, and signed Linux updater bundles
|
||||
→ build-windows job:
|
||||
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
|
||||
@@ -956,11 +971,13 @@ push stable-vYYYY.M.D tag
|
||||
→ build VitePress public docs into the GitHub Pages root
|
||||
→ build static HTML release history page at /releases/
|
||||
→ publish stable/latest.json
|
||||
→ publish stable/download/ and download/ as permanent download pages that keep the browser page visible while the platform installer starts
|
||||
→ publish stable/download/ and download/ as permanent download pages that keep the browser page visible while the platform installer starts, default Linux visitors to AppImage, and expose RPM as a manual Linux option when the stable release includes one
|
||||
→ preserve alpha/latest.json
|
||||
→ deploy to gh-pages
|
||||
```
|
||||
|
||||
Linux AppImage release jobs use Tauri's stock linuxdeploy AppImage output plugin. `scripts/appimage-launcher-tools.mjs` remains available for local experiments with symlink-safe AppRun patching and fcitx module bundling, but release packaging does not pre-seed that shim because linuxdeploy currently exits before sealing the AppImage when the shim replaces the stock output plugin in Tauri's tools cache.
|
||||
|
||||
### Versioning
|
||||
|
||||
- Stable promotions use git tags in the form `stable-vYYYY.M.D` and stamp the technical version `YYYY.M.D`.
|
||||
@@ -1075,7 +1092,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, OpenCode, Pi, Gemini)
|
||||
- CLI AI agent streaming (Claude, Codex, OpenCode, Pi, Gemini, Kiro)
|
||||
- MCP registration and status
|
||||
- Menu state updates
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ On some Wayland systems, the Linux AppImage may fail to launch with:
|
||||
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
|
||||
```
|
||||
|
||||
Recent Tolaria AppImages automatically disable unstable WebKitGTK AppImage rendering paths and retry startup with an architecture-matching system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
|
||||
Recent Tolaria Linux builds automatically disable unstable WebKitGTK rendering paths on Wayland and AppImage launches. AppImage launches also retry startup with an architecture-matching system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
|
||||
|
||||
```bash
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/usr/lib64/libwayland-client.so.0 ./Tolaria*.AppImage
|
||||
@@ -45,6 +45,16 @@ WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/u
|
||||
|
||||
If your distribution stores the 64-bit library elsewhere, use that path instead, for example `/usr/lib/x86_64-linux-gnu/libwayland-client.so.0`. On 64-bit Fedora, avoid `/usr/lib/libwayland-client.so.0`; that path can point at a 32-bit library and be ignored by the loader with a wrong ELF class warning.
|
||||
|
||||
### Linux AppImage packaging checks
|
||||
|
||||
Linux release CI currently uses Tauri's stock linuxdeploy AppImage output plugin:
|
||||
|
||||
```bash
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||
```
|
||||
|
||||
Release validation verifies that the Linux job produced an AppImage, at least one installer bundle, and updater signature artifacts. The experimental AppImage output-plugin shim in `scripts/appimage-launcher-tools.mjs` is retained for local investigation, but it is not wired into release packaging because linuxdeploy currently exits before sealing the AppImage when the shim is pre-seeded in Tauri's tools cache.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -75,7 +85,7 @@ Linux AppImage builds still use the user's system `git` and `node`. Before Tolar
|
||||
|
||||
The `settings.multi_workspace_enabled` flag turns the registered vault list into a unified graph. When enabled, `useVaultLoader` loads every available mounted vault, annotates entries with workspace provenance, and lets note lists, quick open, keyword search, backlinks, and wikilink navigation span those vaults.
|
||||
|
||||
The selected/default vault remains the write and repository focus. New notes and Type documents use `defaultWorkspacePath` when it points at an available mounted vault, while Git status, commits, sync, folder tree, repair, and watcher behavior stay scoped to explicit repository roots. Saved Views are listed from every mounted vault with source-vault identity, so duplicate view filenames remain separate and edits persist back to the view's owning vault.
|
||||
The selected/default vault remains the write target for new notes and Type documents when `defaultWorkspacePath` points at an available mounted vault. Git status, changes, AutoGit checkpointing, and sync operate across the active mounted repository set, while history, diff, repair, and file operations still resolve explicit repository roots from the selected surface or entry provenance. Saved Views are listed from every mounted vault with source-vault identity, so duplicate view filenames remain separate and edits persist back to the view's owning vault.
|
||||
|
||||
The bottom-left `VaultMenu` exposes quick include/exclude controls and a `Manage vaults` entry. The Vaults settings section owns the full identity controls: display name, short label, read-only alias, accent color, removal, and default destination for new notes.
|
||||
|
||||
@@ -115,7 +125,7 @@ tolaria/
|
||||
│ │ ├── CommandPalette.tsx # Cmd+K command launcher
|
||||
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
|
||||
│ │ ├── WelcomeScreen.tsx # Onboarding screen
|
||||
│ │ ├── LinuxTitlebar.tsx # Linux-only custom window chrome + controls
|
||||
│ │ ├── LinuxTitlebar.tsx # Linux/Windows custom window chrome + controls
|
||||
│ │ ├── LinuxMenuButton.tsx # Linux titlebar menu mirroring app commands
|
||||
│ │ ├── CloneVaultModal.tsx # Clone a vault from any git URL
|
||||
│ │ ├── AddRemoteModal.tsx # Connect a local-only vault to a remote later
|
||||
@@ -141,7 +151,7 @@ tolaria/
|
||||
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
|
||||
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
|
||||
│ │ ├── aiAgentPermissionMode.ts # Safe/Power User mode normalization + labels
|
||||
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi/Gemini availability polling
|
||||
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi/Gemini/Kiro availability polling
|
||||
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
|
||||
│ │ ├── useAiActivity.ts # MCP UI bridge listener
|
||||
│ │ ├── useAutoSync.ts # Auto git pull/push
|
||||
@@ -221,6 +231,7 @@ tolaria/
|
||||
│ │ ├── claude_cli.rs # Claude CLI adapter
|
||||
│ │ ├── codex_cli.rs # Codex CLI adapter
|
||||
│ │ ├── pi_cli.rs # Pi CLI adapter
|
||||
│ │ ├── kiro_cli.rs # Kiro CLI adapter
|
||||
│ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal
|
||||
│ │ ├── app_updater.rs # Alpha/stable updater metadata resolution
|
||||
│ │ ├── settings.rs # App settings persistence
|
||||
@@ -229,7 +240,7 @@ tolaria/
|
||||
│ │ └── menu.rs # Native macOS menu bar
|
||||
│ └── icons/ # App icons
|
||||
│
|
||||
├── mcp-server/ # MCP bridge (Node.js)
|
||||
├── mcp-server/ # MCP bridge (Node.js or Bun)
|
||||
│ ├── index.js # MCP server entry (stdio, 14 tools)
|
||||
│ ├── vault.js # Vault file operations
|
||||
│ ├── ws-bridge.js # WebSocket bridge (ports 9710, 9711)
|
||||
@@ -297,7 +308,7 @@ tolaria/
|
||||
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
|
||||
| `src-tauri/src/ai_agents.rs` | CLI-agent request normalization, availability aggregation, adapter dispatch, and Claude event mapping. |
|
||||
| `src-tauri/src/cli_agent_runtime.rs` | Shared CLI-agent request shape, prompt wrapping, JSON subprocess lifecycle, version probing, and MCP path helpers. |
|
||||
| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `src-tauri/src/gemini_cli.rs` | Per-agent command, config, discovery, and JSON event adapters. |
|
||||
| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `src-tauri/src/gemini_cli.rs`, `src-tauri/src/kiro_cli.rs` | Per-agent command, config, discovery, and event adapters. |
|
||||
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — resolves alpha/stable manifests and streams install progress. |
|
||||
|
||||
### Editor
|
||||
@@ -335,12 +346,12 @@ tolaria/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, date display format, auto-sync interval, default note width, sidebar type pluralization, default AI agent). |
|
||||
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, date display format, Git visibility, auto-sync interval, default note width, sidebar type pluralization, default AI agent). |
|
||||
| `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). |
|
||||
| `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. |
|
||||
| `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, AI permission mode). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, content display preferences, default AI agent, and the vault-level explicit organization toggle. |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, Git setup prompt preference, AI permission mode). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, Git visibility, sync interval, UI language, content display preferences, default AI agent, and the vault-level explicit organization toggle. |
|
||||
| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
|
||||
|
||||
## Architecture Patterns
|
||||
@@ -376,7 +387,7 @@ type SidebarSelection =
|
||||
|
||||
### Command Registry
|
||||
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the Light/Dark/System theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Windows, native menu clicks arrive from the main `WebviewWindow`, so `src-tauri/src/menu.rs` must keep its window-scoped menu event handler in addition to the app-level handler. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the Light/Dark/System theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux and Windows, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because those builds use Tolaria's custom chrome instead of the native desktop menu bar. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
|
||||
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
|
||||
|
||||
@@ -457,12 +468,13 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
|
||||
4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts`
|
||||
5. **Shared CLI runtime behavior**: Edit `src-tauri/src/cli_agent_runtime.rs` for process lifecycle, prompt wrapping, version probing, and common Tolaria MCP path handling.
|
||||
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`). Keep Codex Safe on `read-only` + `untrusted` and Codex Power User on active-vault `workspace-write` + `never`, keep Pi and Gemini on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. Pi's transient agent directory must be seeded from the user's existing Pi agent directory before Tolaria MCP is merged so standalone provider/auth setup keeps working. Gemini Power User intentionally uses Gemini's `yolo` mode per ADR-0103.
|
||||
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`, `kiro_*`). Keep Codex Safe on `read-only` + `untrusted` and Codex Power User on active-vault `workspace-write` + `never`, keep Pi, Gemini, and Kiro on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. Pi's transient agent directory must be seeded from the user's existing Pi agent directory before Tolaria MCP is merged so standalone provider/auth setup keeps working. Gemini Power User intentionally uses Gemini's `yolo` mode per ADR-0103. Kiro receives prompt content over stdin and writes Tolaria MCP config into `.kiro/settings/mcp.json` in the active vault.
|
||||
|
||||
### Work with external MCP setup
|
||||
|
||||
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows executable-adjacent installs such as `%LOCALAPPDATA%\Tolaria`, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
|
||||
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
|
||||
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs` and its `src-tauri/src/mcp/` helpers; registration and manual config generation must resolve an MCP runtime via `find_mcp_runtime` (Node.js 18+ preferred, Bun 1+ fallback) first, resolve the packaged `mcp-server/` for macOS, Windows executable-adjacent installs such as `%LOCALAPPDATA%\Tolaria`, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), and AppImage installs, and use a vault-neutral entry with `WS_UI_PORT=9711`. Linux AppImage startup must extract `mcp-server/` to `~/.local/share/tolaria/mcp-server/` before durable registration uses that stable path. App-owned bridge launches still pass `VAULT_PATH`/`VAULT_PATHS`; durable external registrations rely on the MCP server reading `vaults.json` at tool-call time.
|
||||
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the runtime prerequisite (Node.js 18+ or Bun 1+), the exact generated manual config, and a copy action before Tolaria writes third-party config files
|
||||
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
|
||||
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; app-managed Gemini sessions still require the user to install and sign in to Gemini CLI, but Tolaria supplies transient MCP settings when Gemini is selected as the default AI agent
|
||||
5. **Process lifecycle**: Stdio MCP servers in `mcp-server/index.js` must exit when their external client closes stdin, and the desktop-owned `ws-bridge.js` child must be stopped on vault deselection, vault switch, and app exit
|
||||
5. **OpenCode compatibility**: Keep `~/.config/opencode/opencode.json` in durable registration. OpenCode uses the top-level `mcp` key, `command` as an array, `environment` for env vars, `type: "local"`, and `enabled: true`; it must remain vault-neutral like the standard `mcpServers` entry.
|
||||
6. **Process lifecycle and vault guidance**: Stdio MCP servers in `mcp-server/index.js` must exit when their external client closes stdin, and the desktop-owned `ws-bridge.js` child must be stopped on vault deselection, vault switch, and app exit. MCP context must include root `AGENTS.md` instructions for every active mounted workspace when those files exist.
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
type: ADR
|
||||
id: "0026"
|
||||
title: "Props-down callbacks-up (no global state management)"
|
||||
status: active
|
||||
status: superseded
|
||||
date: 2026-02-15
|
||||
superseded_by: "0115"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
type: ADR
|
||||
id: "0110"
|
||||
title: "In-app media and PDF previews for binary vault files"
|
||||
status: active
|
||||
status: superseded
|
||||
date: 2026-05-05
|
||||
supersedes: "0098"
|
||||
superseded_by: "0121"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0115"
|
||||
title: "Scoped React Context for shared UI preferences"
|
||||
status: active
|
||||
date: 2026-05-12
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Laputa has relied on props-down callbacks-up state flow since ADR-0026 because most renderer state is orchestrated in `App.tsx` and the component tree stays understandable. Today's `date_display_format` refactor exposed a narrow exception: the same installation-local rendering preference now needs to reach note rows, property chips and cells, inspector surfaces, table-of-contents metadata, search subtitles, and date-editing controls across multiple branches of the tree. Continuing to thread that value through intermediate components would add noisy prop plumbing to components that do not conceptually own the preference.
|
||||
|
||||
## Decision
|
||||
|
||||
**Use a scoped React context for shared UI preferences that are read in many renderer leaves but still sourced from `App.tsx`. `AppPreferencesProvider` publishes the current installation-local preference values, and leaf components consume them through focused hooks such as `useDateDisplayFormat`; writes still flow through the existing settings/update path rather than through context mutations.**
|
||||
|
||||
## Alternatives considered
|
||||
- **Scoped app-preferences context** (chosen): removes prop forwarding for cross-cutting rendering preferences while keeping the source of truth in `App.tsx` and avoiding a general-purpose global store.
|
||||
- **Continue prop drilling from `App.tsx`**: preserves the old rule literally, but keeps widening component signatures and couples intermediate components to preferences they do not use.
|
||||
- **Adopt a broader global state/store solution**: centralizes access, but introduces more indirection and policy surface than this renderer-only preference case needs.
|
||||
|
||||
## Consequences
|
||||
|
||||
Leaf components can read shared formatting preferences directly, so `date_display_format` stays consistent across note-list, inspector, search, and metadata surfaces without forwarding props through unrelated layers.
|
||||
|
||||
This narrows ADR-0026's blanket "no Context for data" rule. The replacement rule is: mutable application/domain state still lives in `App.tsx` plus focused hooks, while React context is allowed only for tightly scoped, cross-cutting UI preferences whose canonical value still originates from that same top-level state.
|
||||
|
||||
Future additions to `AppPreferencesProvider` should stay small, renderer-local, and read-focused. If Laputa starts moving writable domain state, async workflows, or large derived objects into context, that needs a new ADR rather than quietly expanding this pattern.
|
||||
24
docs/adr/0117-appimage-fcitx-gtk3-frontend-bundle.md
Normal file
24
docs/adr/0117-appimage-fcitx-gtk3-frontend-bundle.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# ADR-0117: Bundle the fcitx GTK3 frontend in Linux AppImages
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Linux AppImages run WebKitGTK through the GTK3 input-method stack. Users with fcitx5 can export `GTK_IM_MODULE=fcitx`, `QT_IM_MODULE=fcitx`, and `XMODIFIERS=@im=fcitx`, but the AppImage still cannot load the host GTK immodule reliably because the GTK module cache and library paths are isolated from the mounted AppDir.
|
||||
|
||||
The previous AppImage startup fallback set `GTK_IM_MODULE=fcitx` when fcitx was detected, but it did not make the `im-fcitx5.so` module available inside the AppImage. That left Chinese/Japanese/Korean input dependent on host paths that GTK may not search from a sealed AppImage.
|
||||
|
||||
## Decision
|
||||
|
||||
Linux release jobs install `fcitx5-frontend-gtk3` and the AppImage output-plugin shim copies the GTK3 fcitx immodule plus its client library into the AppDir before the AppImage is sealed. At runtime, AppImage startup writes a mount-path-specific `GTK_IM_MODULE_FILE` cache that points GTK at the bundled module whenever fcitx is configured explicitly or through common fcitx environment hints.
|
||||
|
||||
The sealed AppImage validation step extracts every produced AppImage and fails the release if the symlink-safe AppRun resolver, the bundled fcitx immodule, or the fcitx client library is missing.
|
||||
|
||||
## Consequences
|
||||
|
||||
- fcitx5 input works in AppImage launches without relying on the host GTK immodule cache path.
|
||||
- X11 fallback launches with explicit `GTK_IM_MODULE=fcitx` use the same bundled module path as Wayland launches.
|
||||
- Linux AppImage builds now depend on the distro package that provides the GTK3 fcitx frontend.
|
||||
- If the Ubuntu package path changes, the AppImage validation step fails before publishing a broken bundle.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0118"
|
||||
title: "Entry-scoped note windows without vault index scans"
|
||||
status: active
|
||||
date: 2026-05-14
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0031 kept secondary note windows on the full `App` shell so they would inherit the same editor capabilities as the primary window. That decision also accepted a full vault load per secondary window as the simpler trade-off.
|
||||
|
||||
In practice, repeated note-window opens were paying that full-vault scan cost even when the window only needed one known note path. The startup path loaded the vault index and passed related entries into the editor even though note-window mode renders a single-note surface. That extra work increased window-open cost and made every secondary window depend on repository-wide entry hydration for a workflow that is intentionally scoped to one note.
|
||||
|
||||
Tolaria still wants note windows to reuse the main App architecture rather than reviving a separate `NoteWindow` shell. The missing decision was how far the shared vault loader should go when the window contract is already narrowed to a single entry.
|
||||
|
||||
## Decision
|
||||
|
||||
**Secondary note windows continue to render the full `App` shell, but they no longer load the full vault index during startup.** In note-window mode, Tolaria skips the shared vault-entry scan, reloads only the requested note entry, and scopes editor entry props to that active note instead of passing repository-wide visible entries.
|
||||
|
||||
This keeps the architectural benefit of ADR-0031 (one window architecture, one editor surface) while changing the data-loading contract for secondary windows from vault-scoped to entry-scoped.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Full `App` shell with entry-scoped loading** (chosen): preserves feature parity in the shared shell while removing unnecessary full-vault scans for a one-note window. Trade-off: note windows should not assume vault-index-derived context is available by default.
|
||||
- **Full `App` shell with full vault scan**: simplest continuation of ADR-0031, but repeats avoidable repository-wide I/O every time a note window opens.
|
||||
- **Dedicated `NoteWindow` shell**: could be lighter still, but reintroduces the architectural drift and duplicated feature work that ADR-0031 intentionally removed.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Opening a secondary note window no longer requires `list_vault`/full entry hydration before the editor can render the requested note.
|
||||
- Repeated note-window opens avoid redundant vault scans and stay aligned with the product contract that these windows are single-note work surfaces.
|
||||
- Features inside note-window mode must treat vault-index-derived data as opt-in; they cannot assume related entries are already loaded just because the full `App` shell is mounted.
|
||||
- ADR-0031 remains directionally valid for shared window architecture, but its original "full vault load per secondary window" trade-off is no longer the operating model.
|
||||
- Re-evaluate if note windows later need immediate repository-wide browsing context, or if future profiling shows the remaining single-entry reload path is still too expensive.
|
||||
@@ -0,0 +1,35 @@
|
||||
# 0119. Vault-Neutral MCP Registration With Mounted Workspace Guidance
|
||||
|
||||
Status: active
|
||||
|
||||
Date: 2026-05-14
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria used to register external MCP clients with a durable `VAULT_PATH` environment variable. That made the copied config easy to inspect, but it also meant Claude Code, Cursor, Gemini CLI, and generic MCP clients stayed pinned to whichever vault was active at setup time.
|
||||
|
||||
Domenico Lupinetti's dynamic-vault MCP proposal in PR #603 pointed in the right direction: MCP clients should follow Tolaria's current workspace state instead of requiring users to reconnect after each vault change. The current app model has also moved from one selected vault toward mounted workspaces, so the MCP server needs to operate on every active mounted vault and load local agent guidance from each vault.
|
||||
|
||||
## Decision
|
||||
|
||||
Durable external MCP registration is vault-neutral. Tolaria still writes an explicit stdio MCP entry, but that entry contains only the Node command, `mcp-server/index.js`, and `WS_UI_PORT=9711`. It no longer writes `VAULT_PATH`.
|
||||
|
||||
The Node MCP entrypoints resolve vaults at tool-call time:
|
||||
|
||||
- Explicit `VAULT_PATH` and `VAULT_PATHS` environment variables continue to win for app-owned bridge launches and legacy/manual launches.
|
||||
- When those env vars are absent, the MCP server reads Tolaria's `vaults.json`.
|
||||
- `active_vault` is returned first.
|
||||
- Every workspace in `vaults[]` is included unless it is explicitly marked `mounted: false`.
|
||||
- Paths are deduplicated and blank paths are ignored.
|
||||
|
||||
Vault context now checks each active mounted workspace root for `AGENTS.md` and returns those instructions with the vault summary. The MCP server also exposes `list_vaults` so agents can discover the active workspace set and whether each vault has root guidance.
|
||||
|
||||
We are not adding a session-local `switch_vault` tool. A switch tool would create a second source of truth inside the MCP process, while Tolaria already owns mounted workspace state.
|
||||
|
||||
## Consequences
|
||||
|
||||
External MCP config survives vault switches and mounted-workspace changes without reconnecting.
|
||||
|
||||
Agents can work across all active mounted vaults and receive the per-vault `AGENTS.md` instructions needed to respect local rules.
|
||||
|
||||
Manual users can still override the resolved workspace set with `VAULT_PATH` or `VAULT_PATHS` when they intentionally want a static or scripted MCP session.
|
||||
@@ -0,0 +1,37 @@
|
||||
# 0120. Stable AppImage MCP Server Path With OpenCode Registration
|
||||
|
||||
Status: active
|
||||
|
||||
Date: 2026-05-14
|
||||
|
||||
## Context
|
||||
|
||||
Domenico Lupinetti's PR #600 identified two gaps in durable external MCP setup:
|
||||
|
||||
- Linux AppImage launches expose bundled resources through a mount path that can change between app starts, so external clients can keep a stale `mcp-server/index.js` path.
|
||||
- OpenCode uses `~/.config/opencode/opencode.json` with a different MCP schema from Claude Code, Cursor, Gemini, and generic `mcpServers` clients.
|
||||
|
||||
ADR-0119 made durable MCP registration vault-neutral, so PR #600 could not be merged directly: its registered entries still carried `VAULT_PATH`. The stable-path and OpenCode work is still valid, but it has to preserve the current mounted-workspace resolution model.
|
||||
|
||||
## Decision
|
||||
|
||||
On Linux AppImage startup, Tolaria extracts the bundled `mcp-server/` directory to `~/.local/share/tolaria/mcp-server/`. The extracted directory is version-gated by a `.tolaria-version` marker. Extraction runs on first launch or after an app version change, uses a staging directory plus rename, and uses a process lock so concurrent app launches do not write the stable directory at the same time.
|
||||
|
||||
Durable external registration prefers the stable extracted server directory when it is ready. Otherwise it falls back to the packaged resource resolver.
|
||||
|
||||
OpenCode is added to durable MCP registration and removal. Tolaria writes an OpenCode-specific entry under the top-level `mcp` key using:
|
||||
|
||||
- `type: "local"`
|
||||
- `command: [node, index.js]`
|
||||
- `enabled: true`
|
||||
- `environment.WS_UI_PORT = "9711"`
|
||||
|
||||
OpenCode registration remains vault-neutral. It does not write `VAULT_PATH`; the Node MCP server resolves active mounted workspaces from Tolaria state per ADR-0119.
|
||||
|
||||
## Consequences
|
||||
|
||||
Linux AppImage users can register external MCP clients once and keep a valid `index.js` path across restarts and updates.
|
||||
|
||||
OpenCode participates in the same connect, disconnect, and status flow as Claude Code, Cursor, Gemini, and generic MCP clients while preserving its own config schema.
|
||||
|
||||
The stable path fixes the packaging lifecycle without reintroducing static vault pinning.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0121"
|
||||
title: "AppImage external fallback for audio and video previews"
|
||||
status: active
|
||||
date: 2026-05-15
|
||||
supersedes: "0110"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0110 standardized in-app previews for image, audio, video, and PDF vault files through the shared `FilePreview` surface and Tauri asset URLs. In practice, Linux AppImage builds run audio and video playback through WebKitGTK, and that runtime has proven unstable enough that mounting the same in-webview media controls is not a reliable default for packaged Linux releases.
|
||||
|
||||
Tolaria still needs one binary-preview model across platforms: previewability should remain renderer-inferred from filename extensions, binary files should remain ordinary vault entries, and external-open actions must continue to re-enter the active-vault command boundary before the OS opens a file.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria keeps in-app image and PDF previews everywhere, but Linux AppImage builds fall back to external-open controls for audio and video instead of mounting in-webview media playback.**
|
||||
|
||||
- `FilePreview` remains the single renderer-owned surface for supported binary vault files.
|
||||
- The preview policy is runtime-owned: the renderer asks the native runtime whether external media fallback is required before rendering audio or video elements.
|
||||
- Linux AppImage builds return `true` for that runtime check and suppress in-webview audio/video previews; other targets keep the existing native HTML media controls.
|
||||
- Editor-embedded BlockNote audio/video blocks follow the same runtime gate so binary preview behavior stays consistent between note bodies and file previews.
|
||||
|
||||
## Alternatives considered
|
||||
- **Runtime-gated external fallback on Linux AppImage** (chosen): keeps one preview architecture while containing a platform-specific runtime instability. Cons: AppImage users lose inline playback for audio/video.
|
||||
- **Keep in-app audio/video previews on every platform**: preserves feature parity, but continues shipping a known unstable playback path on AppImage.
|
||||
- **Disable all binary previews on Linux**: simpler policy, but unnecessarily removes stable image/PDF previews and weakens the file-first editor experience.
|
||||
|
||||
## Consequences
|
||||
|
||||
Tolaria now treats audio/video preview as a runtime capability decision rather than a universal guarantee of the binary preview system. Linux AppImage users see explicit external-open fallback controls for audio and video, while other platforms keep the richer in-app playback path.
|
||||
|
||||
This keeps the filesystem-first binary model, scoped asset access, and active-vault validation boundary intact without introducing persisted media types or a separate media subsystem. Re-evaluate this decision if AppImage media playback becomes stable enough to restore inline playback without special handling, or if other packaged runtimes need their own preview capability gates.
|
||||
33
docs/adr/0122-scalar-array-frontmatter-properties.md
Normal file
33
docs/adr/0122-scalar-array-frontmatter-properties.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 0122. Scalar Array Frontmatter Properties
|
||||
|
||||
Status: active
|
||||
|
||||
Date: 2026-05-15
|
||||
|
||||
## Context
|
||||
|
||||
Saved Views filter against `VaultEntry.properties` in the renderer and in the Rust view evaluator. Before this decision, Tolaria preserved custom scalar frontmatter values as properties but dropped multi-element non-wikilink arrays during a full vault scan. That made a view such as `tags / contains / blues` unstable: optimistic renderer state could see a changed array for a while, but reload, view switch, or restart rebuilt the entry without the array-backed property.
|
||||
|
||||
Relationship arrays already have separate semantics because wikilink-bearing fields are stored in `VaultEntry.relationships`. Plain scalar arrays need to stay queryable as custom properties without being treated as relationship fields.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria preserves custom scalar-array frontmatter fields in `VaultEntry.properties`, while wikilink-bearing arrays remain relationships.** Single-item scalar arrays continue to normalize to a scalar value for compatibility; multi-item scalar arrays remain arrays.
|
||||
|
||||
Saved View filters evaluate scalar-array properties with set semantics. `contains` and `any_of` match exact case-insensitive elements, not substrings inside an element. Scalar properties keep their existing case-insensitive text matching behavior.
|
||||
|
||||
The vault cache version is bumped so existing caches that dropped array properties are rebuilt from disk.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A (chosen): Preserve scalar arrays as properties** - keeps YAML frontmatter expressive, fixes reload/restart behavior, and avoids hardcoded fields such as `tags`. The cost is widening `VaultEntry.properties` from scalar-only to scalar-or-array.
|
||||
- **Option B: Flatten arrays to comma-delimited strings** - keeps the old property type, but cannot distinguish exact elements from substrings and makes filters ambiguous.
|
||||
- **Option C: Treat every array as a relationship** - reuses existing relationship matching, but non-wikilink values such as tags are not graph edges and should not appear as relationships.
|
||||
|
||||
## Consequences
|
||||
|
||||
Views can filter custom scalar arrays consistently across save, reload, view switches, and app restart.
|
||||
|
||||
Property chips and sorting must tolerate property arrays. The note-list chip resolver already expands array values; custom-property sorting falls back to string comparison for arrays.
|
||||
|
||||
Any future custom-property logic must handle `VaultPropertyValue` rather than assuming every property is a scalar.
|
||||
@@ -81,7 +81,7 @@ proposed → active → superseded
|
||||
| [0023](0023-repair-vault-auto-bootstrap.md) | Repair Vault auto-bootstrap pattern | active |
|
||||
| [0024](0024-cache-outside-vault.md) | Vault cache stored outside vault directory | active |
|
||||
| [0025](0025-type-field-canonical.md) | type: as canonical field (replacing Is A:) | active |
|
||||
| [0026](0026-props-down-no-global-state.md) | Props-down callbacks-up (no global state) | active |
|
||||
| [0026](0026-props-down-no-global-state.md) | Props-down callbacks-up (no global state) | superseded → [0115](0115-scoped-react-context-for-shared-ui-preferences.md) |
|
||||
| [0027](0027-dual-ai-architecture.md) | Dual AI architecture (API chat + CLI agent) | superseded |
|
||||
| [0028](0028-cli-agent-only-no-api-key.md) | CLI agent only — no direct Anthropic API key | active |
|
||||
| [0029](0029-domain-command-builder-pattern.md) | Domain command builder pattern for useCommandRegistry | active |
|
||||
@@ -163,9 +163,15 @@ proposed → active → superseded
|
||||
| [0107](0107-pointer-owned-editor-block-reordering.md) | Pointer-owned editor block reordering | active |
|
||||
| [0108](0108-sanitized-rendered-markup-and-safe-regex.md) | Sanitized rendered markup and safe user regex | active |
|
||||
| [0109](0109-debounced-worker-derived-editor-indexes.md) | Debounced worker-derived editor indexes | active |
|
||||
| [0110](0110-in-app-media-and-pdf-file-previews.md) | In-app media and PDF previews for binary vault files | active |
|
||||
| [0110](0110-in-app-media-and-pdf-file-previews.md) | In-app media and PDF previews for binary vault files | superseded → [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) |
|
||||
| [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | active |
|
||||
| [0112](0112-system-theme-mode.md) | System theme mode | active |
|
||||
| [0113](0113-shared-renderer-attachment-path-normalization.md) | Shared renderer attachment path normalization | active |
|
||||
| [0114](0114-mounted-workspaces-unified-graph.md) | Mounted workspaces unified graph | active |
|
||||
| [0115](0115-scoped-react-context-for-shared-ui-preferences.md) | Scoped React Context for shared UI preferences | active |
|
||||
| [0116](0116-rich-raw-transition-and-serialization-ownership.md) | Rich/raw transition and serialization ownership | active |
|
||||
| [0118](0118-entry-scoped-note-windows-without-vault-index-scans.md) | Entry-scoped note windows without vault index scans | active |
|
||||
| [0119](0119-vault-neutral-mcp-registration-with-mounted-workspace-guidance.md) | Vault-neutral MCP registration with mounted workspace guidance | active |
|
||||
| [0120](0120-stable-appimage-mcp-server-path-with-opencode-registration.md) | Stable AppImage MCP server path with OpenCode registration | active |
|
||||
| [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) | AppImage external fallback for audio and video previews | active |
|
||||
| [0122](0122-scalar-array-frontmatter-properties.md) | Scalar array frontmatter properties | active |
|
||||
|
||||
16
lara.lock
16
lara.lock
@@ -59,6 +59,7 @@ files:
|
||||
command.git.commitPush: 8cb5faa4019716f43dd69c41496b1d46
|
||||
command.git.addRemote: 7eef951b3f909340a68dc9811be83e9e
|
||||
command.git.pull: 3988d61f4a4546381f61a4eaf61315b4
|
||||
command.git.pullRepository: 931ad25176586fd4332dd671574d3185
|
||||
command.git.resolveConflicts: 871ac36968cfca3d2297a89b28b25dee
|
||||
command.git.viewChanges: b2699edf69d8e1031afce2b2f94e5c8d
|
||||
git.repository.select: 33fcf2b3ec4686d9cd06051c726d0ba2
|
||||
@@ -127,6 +128,8 @@ files:
|
||||
settings.workspaces.labelTooltip: b1b87a2affcd504e54bf97100e3d9c34
|
||||
settings.workspaces.slugTooltip: daa690f5855e35d1cfa9dbffe1870d40
|
||||
settings.workspaces.color: cb5feb1b7314637725a2e73bdc9f7295
|
||||
settings.workspaces.moveUpAria: 2ade43872ef9432d14af83056304c6d7
|
||||
settings.workspaces.moveDownAria: ed1ef4a310797ac84894a224bf4c77a6
|
||||
settings.workspaces.removeAria: a5d2ceeeb76bfcb99015fc780ced07c2
|
||||
settings.appearance.title: a1c58e94227389415de133efdf78ea6e
|
||||
settings.appearance.description: ca7b75a4c10ab8a540707e3a96cd3592
|
||||
@@ -139,9 +142,12 @@ files:
|
||||
settings.language.label: 244c7b77926f077b863c33ff1244e1ec
|
||||
settings.language.system: 41e2252344aa441e925589ddcb762e6d
|
||||
settings.language.summary: edc83f1f48ce29bc80bf2f2f90e24997
|
||||
settings.autogit.title: e59f720605ec1cfa42b3b97d89d7e883
|
||||
settings.autogit.title: 0bcc70105ad279503e31fe7b3f47b665
|
||||
settings.git.enable: c40503e1b0f483eecf3aff2fd01665c4
|
||||
settings.git.enableDescription: a96a43dc4dbf1490d665eef437807b07
|
||||
settings.autogit.description.enabled: e65bec70ca4d7921be56f84d10836018
|
||||
settings.autogit.description.disabled: 1143ef6ee00614816785e3c6fc299d51
|
||||
settings.autogit.description.gitDisabled: 55a1dd2d7f797062cde1374930221649
|
||||
settings.autogit.enable: 9b205a4ae0e3ad4e6708155880ce9c75
|
||||
settings.autogit.enableDescription: a7424a8f90c0b7f290a8144d12374554
|
||||
settings.autogit.idleThreshold: 28016cc6fccd951a904ee3020cd733b6
|
||||
@@ -152,7 +158,7 @@ files:
|
||||
settings.titles.description: b94aeeca78dd376cc19b9aa019e53f6c
|
||||
settings.titles.autoRename: 5383db8e790ebf5f956d9c59cb4c4f67
|
||||
settings.titles.autoRenameDescription: cc28c28d8817736e658082a2261d9a6e
|
||||
settings.vaultContent.title: 78b883ce9acb9c25e611158a518d9185
|
||||
settings.vaultContent.title: f15c1cae7882448b3fb0404682e17e61
|
||||
settings.vaultContent.description: a53487bf1c7898a1459dc1510e216f7d
|
||||
settings.dateDisplay.default: 915e8ca934abe6625e17695ee7ae266a
|
||||
settings.dateDisplay.defaultDescription: 502ed7ff7b9c0fe5971e8cb0576e91f6
|
||||
@@ -310,7 +316,7 @@ files:
|
||||
mcp.setup.disconnect: 42ae25231906c83927831e0ef7c317ac
|
||||
mcp.setup.connecting: 182e7eda4e721ad00737460b88701dee
|
||||
mcp.setup.disconnecting: 11de134aefe51cb4a5c545b5a057a871
|
||||
mcp.setup.nodeRequirement: ac58a35cf21d2204fed548c227f77594
|
||||
mcp.setup.runtimeRequirement: 1d7a8c99b2bc875b3601fa57860432fb
|
||||
mcp.setup.writeEntryDescription: 370aa4403d2ffc37f574503bc3cd00e6
|
||||
mcp.setup.clientPathsDescription: 94565852412051c799e78d2f4525ff53
|
||||
mcp.setup.geminiGuidanceDescription: 182cf5b84b2f56cb1cf41dcdb5e093d8
|
||||
@@ -381,6 +387,8 @@ files:
|
||||
noteList.title.notes: f4c6f851b00d5518bf888815de279aba
|
||||
noteList.searchPlaceholder: 4857cd2689a0a40eb4a77cdc745c518e
|
||||
noteList.searchAction: 5012120fc480c67686dd22ee2f9493cd
|
||||
noteList.clearSearch: 9983381c21069a3d6e4432e0aaea0079
|
||||
noteList.quickOpenCreate: a4e964a3bc5b25e7ae87163624731e05
|
||||
noteList.createNote: f1484bc40bd3fe3430c13fb89e2ce1af
|
||||
noteList.empty.changesError: 4fca500c4b70f61f7d9413c57c34bdc4
|
||||
noteList.empty.noChanges: ad82ac153532f5625760c29c176c5d5f
|
||||
@@ -655,3 +663,5 @@ files:
|
||||
locale.koKR: d0bdb3cde477d82e766da05ebda50ccb
|
||||
locale.vi: 7b80fae85640c16cdb0261bef0c27636
|
||||
locale.plPL: c730389bc8d99e59c867766babdd48b5
|
||||
locale.beBY: 0a6de4460095f398e8a45ed512a7538f
|
||||
locale.beLatn: 9c454dccccd6aaac0121a45b923dc5ad
|
||||
|
||||
23
mcp-server/agent-instructions.js
Normal file
23
mcp-server/agent-instructions.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { vaultContext } from './vault.js'
|
||||
|
||||
export async function readAgentInstructions(vaultPath) {
|
||||
const instructionsPath = path.join(vaultPath, 'AGENTS.md')
|
||||
try {
|
||||
return {
|
||||
path: instructionsPath,
|
||||
content: await readFile(instructionsPath, 'utf8'),
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function vaultContextWithInstructions(vaultPath) {
|
||||
return {
|
||||
...(await vaultContext(vaultPath)),
|
||||
agentInstructions: await readAgentInstructions(vaultPath),
|
||||
}
|
||||
}
|
||||
@@ -19,14 +19,19 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import WebSocket from 'ws'
|
||||
import { searchNotes, getNote, vaultContext } from './vault.js'
|
||||
import { searchNotes, getNote } from './vault.js'
|
||||
import { requireVaultPaths } from './vault-path.js'
|
||||
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
|
||||
import path from 'node:path'
|
||||
|
||||
const VAULT_PATHS = requireVaultPaths()
|
||||
const PRIMARY_VAULT_PATH = VAULT_PATHS[0]
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
|
||||
const LOCAL_READ_ONLY_TOOL_ANNOTATIONS = Object.freeze({
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
})
|
||||
|
||||
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
|
||||
// The bridge relays messages to all other clients (the React frontend).
|
||||
@@ -35,6 +40,10 @@ let reconnectTimer = null
|
||||
let shutdownStarted = false
|
||||
const RECONNECT_INTERVAL_MS = 3000
|
||||
|
||||
function activeVaultPaths() {
|
||||
return requireVaultPaths()
|
||||
}
|
||||
|
||||
function connectUiBridge() {
|
||||
if (shutdownStarted) return
|
||||
|
||||
@@ -104,6 +113,7 @@ const TOOLS = [
|
||||
{
|
||||
name: 'search_notes',
|
||||
description: 'Full-text search across vault notes by title or content. Returns matching paths, titles, and snippets.',
|
||||
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -115,7 +125,8 @@ const TOOLS = [
|
||||
},
|
||||
{
|
||||
name: 'get_vault_context',
|
||||
description: 'Get vault orientation for the active Tolaria vaults: entity types, note count, folders, and recent notes.',
|
||||
description: 'Get vault orientation for the active Tolaria vaults: entity types, AGENTS.md instructions, note count, folders, and recent notes.',
|
||||
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -123,9 +134,19 @@ const TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_vaults',
|
||||
description: 'List the current active Tolaria vaults available to MCP tools, including whether each vault has AGENTS.md instructions.',
|
||||
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_note',
|
||||
description: 'Read a note with parsed YAML frontmatter and markdown content. Returns {path, frontmatter, content}.',
|
||||
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -138,6 +159,7 @@ const TOOLS = [
|
||||
{
|
||||
name: 'open_note',
|
||||
description: 'Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.',
|
||||
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -150,6 +172,7 @@ const TOOLS = [
|
||||
{
|
||||
name: 'highlight_editor',
|
||||
description: 'Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
|
||||
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -162,6 +185,7 @@ const TOOLS = [
|
||||
{
|
||||
name: 'refresh_vault',
|
||||
description: 'Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.',
|
||||
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -175,7 +199,7 @@ const TOOLS = [
|
||||
function requestedVaultPath(args = {}) {
|
||||
const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : ''
|
||||
if (!requested) return null
|
||||
if (!VAULT_PATHS.includes(requested)) {
|
||||
if (!activeVaultPaths().includes(requested)) {
|
||||
throw new Error(`Vault is not active in Tolaria: ${requested}`)
|
||||
}
|
||||
return requested
|
||||
@@ -194,7 +218,7 @@ function withVaultMetadata(note, vaultPath) {
|
||||
}
|
||||
|
||||
async function getNoteFromActiveVaults(notePath, vaultPath = null) {
|
||||
const candidates = vaultPath ? [vaultPath] : VAULT_PATHS
|
||||
const candidates = vaultPath ? [vaultPath] : activeVaultPaths()
|
||||
const matches = []
|
||||
const errors = []
|
||||
|
||||
@@ -217,7 +241,7 @@ async function searchActiveVaults(query, limit = 10) {
|
||||
const requestedLimit = Number.isFinite(limit) && limit > 0 ? limit : 10
|
||||
const results = []
|
||||
|
||||
for (const vaultPath of VAULT_PATHS) {
|
||||
for (const vaultPath of activeVaultPaths()) {
|
||||
const vaultResults = await searchNotes(vaultPath, query, requestedLimit)
|
||||
results.push(...vaultResults.map((result) => withVaultMetadata(result, vaultPath)))
|
||||
if (results.length >= requestedLimit) break
|
||||
@@ -227,18 +251,20 @@ async function searchActiveVaults(query, limit = 10) {
|
||||
}
|
||||
|
||||
async function activeVaultContext(targetVaultPath = null) {
|
||||
if (targetVaultPath) return vaultContext(targetVaultPath)
|
||||
if (VAULT_PATHS.length === 1) return vaultContext(PRIMARY_VAULT_PATH)
|
||||
const roots = activeVaultPaths()
|
||||
if (targetVaultPath) return vaultContextWithInstructions(targetVaultPath)
|
||||
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
|
||||
|
||||
return {
|
||||
vaults: await Promise.all(VAULT_PATHS.map(vaultContext)),
|
||||
vaults: await Promise.all(roots.map(vaultContextWithInstructions)),
|
||||
}
|
||||
}
|
||||
|
||||
function uiPath(args = {}) {
|
||||
const notePath = typeof args.path === 'string' ? args.path : ''
|
||||
if (path.isAbsolute(notePath)) return notePath
|
||||
const vaultPath = requestedVaultPath(args) ?? (VAULT_PATHS.length === 1 ? PRIMARY_VAULT_PATH : '')
|
||||
const roots = activeVaultPaths()
|
||||
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '')
|
||||
return vaultPath ? path.join(vaultPath, notePath) : notePath
|
||||
}
|
||||
|
||||
@@ -255,6 +281,20 @@ async function handleVaultContext(args = {}) {
|
||||
return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
|
||||
}
|
||||
|
||||
async function handleListVaults() {
|
||||
const vaults = await Promise.all(activeVaultPaths().map(async (vaultPath) => {
|
||||
const agentInstructions = await readAgentInstructions(vaultPath)
|
||||
return {
|
||||
path: vaultPath,
|
||||
label: vaultLabel(vaultPath),
|
||||
agentInstructionsPath: agentInstructions?.path ?? null,
|
||||
hasAgentInstructions: agentInstructions !== null,
|
||||
}
|
||||
}))
|
||||
|
||||
return { content: [{ type: 'text', text: JSON.stringify({ vaults }, null, 2) }] }
|
||||
}
|
||||
|
||||
async function handleGetNote(args) {
|
||||
const note = await getNoteFromActiveVaults(args.path, requestedVaultPath(args))
|
||||
return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] }
|
||||
@@ -279,23 +319,20 @@ function handleRefreshVault(args) {
|
||||
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
|
||||
}
|
||||
|
||||
const TOOL_HANDLERS = new Map([
|
||||
['search_notes', handleSearchNotes],
|
||||
['get_vault_context', handleVaultContext],
|
||||
['list_vaults', handleListVaults],
|
||||
['get_note', handleGetNote],
|
||||
['open_note', handleOpenNote],
|
||||
['highlight_editor', handleHighlightEditor],
|
||||
['refresh_vault', handleRefreshVault],
|
||||
])
|
||||
|
||||
function callToolHandler(name, args) {
|
||||
switch (name) {
|
||||
case 'search_notes':
|
||||
return handleSearchNotes(args)
|
||||
case 'get_vault_context':
|
||||
return handleVaultContext(args)
|
||||
case 'get_note':
|
||||
return handleGetNote(args)
|
||||
case 'open_note':
|
||||
return handleOpenNote(args)
|
||||
case 'highlight_editor':
|
||||
return handleHighlightEditor(args)
|
||||
case 'refresh_vault':
|
||||
return handleRefreshVault(args)
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
const handler = TOOL_HANDLERS.get(name)
|
||||
if (!handler) throw new Error(`Unknown tool: ${name}`)
|
||||
return handler(args)
|
||||
}
|
||||
|
||||
// --- Server setup ---
|
||||
@@ -358,7 +395,7 @@ async function main() {
|
||||
|
||||
connectUiBridge()
|
||||
await server.connect(transport)
|
||||
console.error(`Tolaria MCP server running (vaults: ${VAULT_PATHS.join(', ')})`)
|
||||
console.error('Tolaria MCP server running (vaults resolved per call)')
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -2,17 +2,20 @@ import { describe, it, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import {
|
||||
mkdtemp, mkdir, open, rm,
|
||||
mkdtemp, mkdir, open, rm, writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { clearTimeout, setTimeout } from 'node:timers'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import {
|
||||
findMarkdownFiles, getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
import { requireVaultPath, requireVaultPaths } from './vault-path.js'
|
||||
import { vaultContextWithInstructions } from './agent-instructions.js'
|
||||
import { evaluateBridgeRequest } from './ws-bridge.js'
|
||||
|
||||
let tmpDir
|
||||
@@ -191,6 +194,26 @@ describe('vaultContext', () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.equal(ctx.noteCount, 4)
|
||||
})
|
||||
|
||||
it('includes root AGENTS.md instructions when present', async () => {
|
||||
const agentsPath = path.join(tmpDir, 'AGENTS.md')
|
||||
await writeFile(agentsPath, '# Vault Rules\n\nUse this vault carefully.\n', 'utf-8')
|
||||
|
||||
try {
|
||||
const ctx = await vaultContextWithInstructions(tmpDir)
|
||||
assert.deepEqual(ctx.agentInstructions, {
|
||||
path: agentsPath,
|
||||
content: '# Vault Rules\n\nUse this vault carefully.\n',
|
||||
})
|
||||
} finally {
|
||||
await rm(agentsPath, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports null agent instructions when AGENTS.md is absent', async () => {
|
||||
const ctx = await vaultContextWithInstructions(tmpDir)
|
||||
assert.equal(ctx.agentInstructions, null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('evaluateBridgeRequest', () => {
|
||||
@@ -236,11 +259,13 @@ describe('requireVaultPath', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects missing vault paths instead of falling back to ~/Laputa', () => {
|
||||
it('rejects missing vault paths instead of falling back to ~/Laputa', async () => {
|
||||
const configDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-empty-config-'))
|
||||
assert.throws(
|
||||
() => requireVaultPath({}),
|
||||
() => requireVaultPaths({}, { configDir }),
|
||||
/VAULT_PATH is required/,
|
||||
)
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns all configured active vault paths with the primary vault first', () => {
|
||||
@@ -252,9 +277,64 @@ describe('requireVaultPath', () => {
|
||||
['/tmp/Default Vault', '/tmp/Second Vault'],
|
||||
)
|
||||
})
|
||||
|
||||
it('loads active mounted vault paths from Tolaria config when env is vault-neutral', async () => {
|
||||
const configDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-config-'))
|
||||
const primaryVault = path.join(configDir, 'Primary Vault')
|
||||
const secondaryVault = path.join(configDir, 'Secondary Vault')
|
||||
const hiddenVault = path.join(configDir, 'Hidden Vault')
|
||||
const configPath = path.join(configDir, 'com.tolaria.app', 'vaults.json')
|
||||
|
||||
await mkdir(path.dirname(configPath), { recursive: true })
|
||||
await writeFile(configPath, JSON.stringify({
|
||||
active_vault: primaryVault,
|
||||
vaults: [
|
||||
{ label: 'Secondary', path: secondaryVault, mounted: true },
|
||||
{ label: 'Hidden', path: hiddenVault, mounted: false },
|
||||
{ label: 'Primary', path: primaryVault, mounted: true },
|
||||
],
|
||||
}), 'utf-8')
|
||||
|
||||
try {
|
||||
assert.deepEqual(
|
||||
requireVaultPaths({}, { configDir }),
|
||||
[primaryVault, secondaryVault],
|
||||
)
|
||||
} finally {
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('stdio process lifecycle', () => {
|
||||
it('advertises local vault tools as approval-safe for MCP clients', async () => {
|
||||
const { client, stderr } = await connectMcpClient()
|
||||
|
||||
try {
|
||||
const { tools } = await client.listTools()
|
||||
const toolsByName = new Map(tools.map(tool => [tool.name, tool]))
|
||||
const safeReadTools = [
|
||||
'search_notes',
|
||||
'get_vault_context',
|
||||
'list_vaults',
|
||||
'get_note',
|
||||
'open_note',
|
||||
'highlight_editor',
|
||||
'refresh_vault',
|
||||
]
|
||||
|
||||
for (const name of safeReadTools) {
|
||||
const tool = toolsByName.get(name)
|
||||
assert.ok(tool, `Missing MCP tool: ${name}`)
|
||||
assert.equal(tool.annotations?.readOnlyHint, true, `${name} should not require destructive approval`)
|
||||
assert.equal(tool.annotations?.destructiveHint, false, `${name} should not be treated as destructive`)
|
||||
assert.equal(tool.annotations?.openWorldHint, false, `${name} should stay scoped to local active vaults`)
|
||||
}
|
||||
} finally {
|
||||
await closeMcpClient(client, stderr)
|
||||
}
|
||||
})
|
||||
|
||||
it('exits when the MCP client closes stdin', async () => {
|
||||
const child = spawn(process.execPath, ['index.js'], {
|
||||
cwd: MCP_SERVER_DIR,
|
||||
@@ -282,6 +362,41 @@ describe('stdio process lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
async function connectMcpClient() {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: ['index.js'],
|
||||
cwd: MCP_SERVER_DIR,
|
||||
env: { ...process.env, VAULT_PATH: tmpDir, WS_UI_PORT: '65534' },
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const stderr = collectTransportStderr(transport)
|
||||
const client = new Client(
|
||||
{ name: 'tolaria-mcp-test-client', version: '0.0.0' },
|
||||
{ capabilities: {} },
|
||||
)
|
||||
|
||||
await client.connect(transport)
|
||||
return { client, stderr }
|
||||
}
|
||||
|
||||
function collectTransportStderr(transport) {
|
||||
const chunks = []
|
||||
transport.stderr?.setEncoding('utf8')
|
||||
transport.stderr?.on('data', chunk => {
|
||||
chunks.push(chunk)
|
||||
})
|
||||
return () => chunks.join('')
|
||||
}
|
||||
|
||||
async function closeMcpClient(client, stderr) {
|
||||
try {
|
||||
await client.close()
|
||||
} catch (error) {
|
||||
assert.fail(`Failed to close MCP test client: ${error.message}\n${stderr()}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
|
||||
const outsideDir = await mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const outsideNote = path.join(outsideDir, 'outside.md')
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { homedir, platform } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const APP_CONFIG_DIR = 'com.tolaria.app'
|
||||
const LEGACY_APP_CONFIG_DIR = 'com.laputa.app'
|
||||
|
||||
function parseVaultPathList(rawValue) {
|
||||
if (!rawValue?.trim()) return []
|
||||
|
||||
@@ -24,17 +31,58 @@ function uniqueVaultPaths(paths) {
|
||||
return unique
|
||||
}
|
||||
|
||||
export function requireVaultPaths(env = process.env) {
|
||||
function appConfigBaseDir(env = process.env) {
|
||||
if (platform() === 'darwin') return join(homedir(), 'Library', 'Application Support')
|
||||
if (platform() === 'win32') return env.APPDATA || join(homedir(), 'AppData', 'Roaming')
|
||||
return env.XDG_CONFIG_HOME || join(homedir(), '.config')
|
||||
}
|
||||
|
||||
export function vaultsJsonPath({ configDir = appConfigBaseDir() } = {}) {
|
||||
const preferred = join(configDir, APP_CONFIG_DIR, 'vaults.json')
|
||||
if (existsSync(preferred)) return preferred
|
||||
|
||||
const legacy = join(configDir, LEGACY_APP_CONFIG_DIR, 'vaults.json')
|
||||
return existsSync(legacy) ? legacy : preferred
|
||||
}
|
||||
|
||||
function pushUniquePath(paths, value) {
|
||||
const path = typeof value === 'string' ? value.trim() : ''
|
||||
if (!path || paths.includes(path)) return
|
||||
paths.push(path)
|
||||
}
|
||||
|
||||
function activeVaultPathsFromList(list) {
|
||||
const paths = []
|
||||
pushUniquePath(paths, list?.active_vault)
|
||||
|
||||
for (const vault of list?.vaults ?? []) {
|
||||
if (vault?.mounted === false) continue
|
||||
pushUniquePath(paths, vault?.path)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
export function configuredVaultPaths({ configDir } = {}) {
|
||||
const filePath = vaultsJsonPath({ configDir })
|
||||
if (!existsSync(filePath)) return []
|
||||
|
||||
return activeVaultPathsFromList(JSON.parse(readFileSync(filePath, 'utf-8')))
|
||||
}
|
||||
|
||||
export function requireVaultPaths(env = process.env, options = {}) {
|
||||
const vaultPaths = uniqueVaultPaths([
|
||||
env.VAULT_PATH?.trim() ?? '',
|
||||
...parseVaultPathList(env.VAULT_PATHS),
|
||||
])
|
||||
if (vaultPaths.length === 0) {
|
||||
const configuredPaths = configuredVaultPaths(options)
|
||||
if (configuredPaths.length > 0) return configuredPaths
|
||||
throw new Error('VAULT_PATH is required. Open a vault in Tolaria before starting MCP tools.')
|
||||
}
|
||||
return vaultPaths
|
||||
}
|
||||
|
||||
export function requireVaultPath(env = process.env) {
|
||||
return requireVaultPaths(env)[0]
|
||||
export function requireVaultPath(env = process.env, options = {}) {
|
||||
return requireVaultPaths(env, options)[0]
|
||||
}
|
||||
|
||||
@@ -22,9 +22,10 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import {
|
||||
getNote, searchNotes, vaultContext,
|
||||
getNote, searchNotes,
|
||||
} from './vault.js'
|
||||
import { requireVaultPaths } from './vault-path.js'
|
||||
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
|
||||
import path from 'node:path'
|
||||
|
||||
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
|
||||
@@ -38,16 +39,10 @@ const TRUSTED_UI_ORIGINS = new Set([
|
||||
|
||||
/** @type {WebSocketServer | null} */
|
||||
let uiBridge = null
|
||||
let vaultPaths = null
|
||||
const UNKNOWN_TOOL = Symbol('unknown tool')
|
||||
|
||||
function activeVaultPaths() {
|
||||
vaultPaths ??= requireVaultPaths()
|
||||
return vaultPaths
|
||||
}
|
||||
|
||||
function primaryVaultPath() {
|
||||
return activeVaultPaths()[0]
|
||||
return requireVaultPaths()
|
||||
}
|
||||
|
||||
function requestedVaultPath(args = {}) {
|
||||
@@ -63,7 +58,7 @@ function uiPath(args = {}) {
|
||||
const notePath = typeof args.path === 'string' ? args.path : ''
|
||||
if (path.isAbsolute(notePath)) return notePath
|
||||
const roots = activeVaultPaths()
|
||||
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? primaryVaultPath() : '')
|
||||
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '')
|
||||
return vaultPath ? path.join(vaultPath, notePath) : notePath
|
||||
}
|
||||
|
||||
@@ -102,8 +97,8 @@ async function searchActiveVaults(query, limit = 10) {
|
||||
|
||||
async function activeVaultContext() {
|
||||
const roots = activeVaultPaths()
|
||||
if (roots.length === 1) return vaultContext(roots[0])
|
||||
return { vaults: await Promise.all(roots.map(vaultContext)) }
|
||||
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
|
||||
return { vaults: await Promise.all(roots.map(vaultContextWithInstructions)) }
|
||||
}
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
@@ -149,11 +144,26 @@ function refreshVaultTool(args) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function listVaultsTool() {
|
||||
return {
|
||||
vaults: await Promise.all(activeVaultPaths().map(async (vaultPath) => {
|
||||
const agentInstructions = await readAgentInstructions(vaultPath)
|
||||
return {
|
||||
path: vaultPath,
|
||||
label: path.basename(vaultPath) || vaultPath,
|
||||
agentInstructionsPath: agentInstructions?.path ?? null,
|
||||
hasAgentInstructions: agentInstructions !== null,
|
||||
}
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const TOOL_EXECUTORS = [
|
||||
['open_note', readNoteTool],
|
||||
['read_note', readNoteTool],
|
||||
['search_notes', (args) => searchActiveVaults(args.query, args.limit)],
|
||||
['vault_context', () => activeVaultContext()],
|
||||
['list_vaults', () => listVaultsTool()],
|
||||
['ui_open_note', uiOpenNoteTool],
|
||||
['ui_open_tab', uiOpenTabTool],
|
||||
['ui_highlight', highlightTool],
|
||||
|
||||
12
package.json
12
package.json
@@ -12,7 +12,7 @@
|
||||
"docs:dev": "vitepress dev site --host 127.0.0.1",
|
||||
"docs:build": "pnpm agent-docs && vitepress build site",
|
||||
"docs:preview": "vitepress preview site --host 127.0.0.1",
|
||||
"lint": "eslint .",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"l10n:translate": "lara-cli translate",
|
||||
"l10n:translate:force": "lara-cli translate --force",
|
||||
"l10n:validate": "node scripts/validate-locales.mjs",
|
||||
@@ -21,7 +21,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/note-history-edit-loop.spec.ts tests/smoke/save-before-note-switch.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/type-derived-properties.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/note-history-edit-loop.spec.ts tests/smoke/quick-open-create-note.spec.ts tests/smoke/save-before-note-switch.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/type-derived-properties.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "node scripts/run-vitest-coverage.mjs",
|
||||
@@ -65,7 +65,6 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "3.4.2",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.564.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"posthog-js": "^1.363.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -124,6 +123,13 @@
|
||||
"postcss": "8.5.10",
|
||||
"protobufjs": "7.5.6",
|
||||
"rollup": "4.59.0"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@blocknote/core@0.46.2": "patches/@blocknote__core@0.46.2.patch",
|
||||
"@blocknote/react@0.46.2": "patches/@blocknote__react@0.46.2.patch",
|
||||
"@tiptap/extension-link@3.19.0": "patches/@tiptap__extension-link@3.19.0.patch",
|
||||
"prosemirror-tables@1.8.5": "patches/prosemirror-tables@1.8.5.patch",
|
||||
"@blocknote/code-block@0.46.2": "patches/@blocknote__code-block@0.46.2.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
86
patches/@blocknote__code-block@0.46.2.patch
Normal file
86
patches/@blocknote__code-block@0.46.2.patch
Normal file
File diff suppressed because one or more lines are too long
@@ -241,7 +241,7 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..ee009c533fa3826819df0b83a12755b6
|
||||
e.state.draggingState = void 0, e.emitUpdate(), n.transact((o) => o.setMeta(D, null)), !n.headless && Ut(n.prosemirrorView.root);
|
||||
},
|
||||
/**
|
||||
@@ -1918,30 +1961,37 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
|
||||
@@ -1918,30 +1961,38 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
|
||||
setCellSelection(o, r, s = r) {
|
||||
if (!e)
|
||||
throw new Error("Table handles view not initialized");
|
||||
@@ -292,8 +292,9 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..ee009c533fa3826819df0b83a12755b6
|
||||
+ return e == null || e.hideHandles({ resetCell: !0 }), !1;
|
||||
return r.orientation === "row" ? r.side === "above" ? pt(a, i) : ft(a, i) : r.side === "left" ? gt(a, i) : wt(a, i);
|
||||
});
|
||||
+ e == null || e.hideHandles({ resetCell: !0, resetDragging: !0 });
|
||||
},
|
||||
@@ -1949,17 +1999,23 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
|
||||
@@ -1949,17 +2000,23 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
|
||||
* Removes a row or column from the table using prosemirror-table commands
|
||||
*/
|
||||
removeRowOrColumn(o, r) {
|
||||
@@ -722,7 +723,7 @@ index 029103600a98bf8ccd3054a5c43ffa2fd9115c06..d1678ed4761baca0b3495dafc8d75152
|
||||
|
||||
destroy() {
|
||||
diff --git a/src/extensions/TableHandles/TableHandles.ts b/src/extensions/TableHandles/TableHandles.ts
|
||||
index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a4c805e27 100644
|
||||
index 3063774..9813bed 100644
|
||||
--- a/src/extensions/TableHandles/TableHandles.ts
|
||||
+++ b/src/extensions/TableHandles/TableHandles.ts
|
||||
@@ -99,6 +99,46 @@ function getChildIndex(node: Element) {
|
||||
@@ -1035,7 +1036,15 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a
|
||||
if (direction.orientation === "row") {
|
||||
if (direction.side === "above") {
|
||||
return addRowBefore(state, dispatch);
|
||||
@@ -1006,12 +1081,19 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
|
||||
@@ -997,6 +1072,7 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
+ view?.hideHandles({ resetCell: true, resetDragging: true });
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1006,12 +1082,19 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
|
||||
index: RelativeCellIndices["row"] | RelativeCellIndices["col"],
|
||||
direction: "row" | "column",
|
||||
) {
|
||||
@@ -1055,7 +1064,7 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a
|
||||
return deleteRow(state, dispatch);
|
||||
});
|
||||
} else {
|
||||
@@ -1020,6 +1102,9 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
|
||||
@@ -1020,6 +1103,9 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
|
||||
row: 0,
|
||||
col: index,
|
||||
});
|
||||
@@ -1065,7 +1074,7 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a
|
||||
return deleteColumn(state, dispatch);
|
||||
});
|
||||
}
|
||||
@@ -1041,6 +1126,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
|
||||
@@ -1041,6 +1127,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
|
||||
)
|
||||
: beforeState;
|
||||
|
||||
@@ -1076,7 +1085,7 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a
|
||||
return mergeCells(state, dispatch);
|
||||
});
|
||||
},
|
||||
@@ -1055,6 +1144,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
|
||||
@@ -1055,6 +1145,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
|
||||
? this.setCellSelection(beforeState, relativeCellToSplit)
|
||||
: beforeState;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/dist/blocknote-react.js b/dist/blocknote-react.js
|
||||
index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf6319df91544 100644
|
||||
index d4d36cd..79dd4f0 100644
|
||||
--- a/dist/blocknote-react.js
|
||||
+++ b/dist/blocknote-react.js
|
||||
@@ -154,8 +154,26 @@ const co = (e) => {
|
||||
@@ -31,7 +31,15 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
|
||||
}
|
||||
const z = (e) => {
|
||||
var h, b, p;
|
||||
@@ -216,9 +234,7 @@ const z = (e) => {
|
||||
@@ -193,6 +211,7 @@ const z = (e) => {
|
||||
...e.elementProps,
|
||||
style: {
|
||||
display: "flex",
|
||||
+ pointerEvents: c === "close" ? "none" : void 0,
|
||||
...(h = e.elementProps) == null ? void 0 : h.style,
|
||||
zIndex: `calc(var(--bn-ui-base-z-index) + ${((p = (b = e.elementProps) == null ? void 0 : b.style) == null ? void 0 : p.zIndex) || 0})`,
|
||||
...n,
|
||||
@@ -216,9 +235,7 @@ const z = (e) => {
|
||||
const s = Ue(t, c.doc);
|
||||
if (!s)
|
||||
return;
|
||||
@@ -42,7 +50,7 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
|
||||
if (a instanceof Element)
|
||||
return {
|
||||
element: a
|
||||
@@ -2499,7 +2515,14 @@ function Kr(e) {
|
||||
@@ -2499,7 +2516,14 @@ function Kr(e) {
|
||||
columns: d
|
||||
} = e, m = H(
|
||||
(C) => {
|
||||
@@ -58,7 +66,7 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
|
||||
},
|
||||
[u, a, s]
|
||||
), { items: g, usedQuery: f, loadingState: h } = Yt(
|
||||
@@ -2734,7 +2757,14 @@ function ti(e) {
|
||||
@@ -2734,7 +2758,14 @@ function ti(e) {
|
||||
onItemClick: u
|
||||
} = e, d = H(
|
||||
(p) => {
|
||||
@@ -74,7 +82,7 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
|
||||
},
|
||||
[u, a, s]
|
||||
), { items: m, usedQuery: g, loadingState: f } = Yt(
|
||||
@@ -3306,14 +3336,15 @@ const ii = (e, t = 0.3) => {
|
||||
@@ -3306,14 +3337,15 @@ const ii = (e, t = 0.3) => {
|
||||
);
|
||||
if (!m)
|
||||
return {};
|
||||
@@ -93,7 +101,7 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
|
||||
return p instanceof Element ? (d.cellReference = { element: p }, d.rowReference = {
|
||||
element: f,
|
||||
getBoundingClientRect: () => {
|
||||
@@ -4371,7 +4402,7 @@ const zi = (e) => {
|
||||
@@ -4371,7 +4403,7 @@ const zi = (e) => {
|
||||
const a = Ue(t, c.prosemirrorState.doc);
|
||||
if (!a)
|
||||
return;
|
||||
|
||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@@ -18,11 +18,14 @@ overrides:
|
||||
rollup: 4.59.0
|
||||
|
||||
patchedDependencies:
|
||||
'@blocknote/code-block@0.46.2':
|
||||
hash: c006cad64c22a2efc10299d85bf72407150fd29a1f16d497b8db0faa071535f8
|
||||
path: patches/@blocknote__code-block@0.46.2.patch
|
||||
'@blocknote/core@0.46.2':
|
||||
hash: e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0
|
||||
hash: 9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2
|
||||
path: patches/@blocknote__core@0.46.2.patch
|
||||
'@blocknote/react@0.46.2':
|
||||
hash: 9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08
|
||||
hash: 67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e
|
||||
path: patches/@blocknote__react@0.46.2.patch
|
||||
'@tiptap/extension-link@3.19.0':
|
||||
hash: fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284
|
||||
@@ -40,16 +43,16 @@ importers:
|
||||
version: 0.78.0(zod@4.3.6)
|
||||
'@blocknote/code-block':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
|
||||
version: 0.46.2(patch_hash=c006cad64c22a2efc10299d85bf72407150fd29a1f16d497b8db0faa071535f8)(@blocknote/core@0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
|
||||
'@blocknote/core':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
version: 0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/mantine':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@blocknote/react':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(patch_hash=9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
version: 0.46.2(patch_hash=67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@codemirror/commands':
|
||||
specifier: ^6.10.2
|
||||
version: 6.10.2
|
||||
@@ -146,9 +149,6 @@ importers:
|
||||
katex:
|
||||
specifier: ^0.16.28
|
||||
version: 0.16.28
|
||||
lucide-react:
|
||||
specifier: ^0.564.0
|
||||
version: 0.564.0(react@19.2.4)
|
||||
mermaid:
|
||||
specifier: ^11.14.0
|
||||
version: 11.14.0
|
||||
@@ -4561,11 +4561,6 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
lucide-react@0.564.0:
|
||||
resolution: {integrity: sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
lz-string@1.5.0:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
@@ -6219,9 +6214,9 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
|
||||
'@blocknote/code-block@0.46.2(patch_hash=c006cad64c22a2efc10299d85bf72407150fd29a1f16d497b8db0faa071535f8)(@blocknote/core@0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
|
||||
dependencies:
|
||||
'@blocknote/core': 0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/core': 0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@shikijs/core': 3.23.0
|
||||
'@shikijs/engine-javascript': 3.23.0
|
||||
'@shikijs/langs': 3.23.0
|
||||
@@ -6229,7 +6224,7 @@ snapshots:
|
||||
'@shikijs/themes': 3.23.0
|
||||
'@shikijs/types': 3.22.0
|
||||
|
||||
'@blocknote/core@0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
|
||||
'@blocknote/core@0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
|
||||
dependencies:
|
||||
'@emoji-mart/data': 1.2.1
|
||||
'@handlewithcare/prosemirror-inputrules': 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
|
||||
@@ -6281,8 +6276,8 @@ snapshots:
|
||||
|
||||
'@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@blocknote/core': 0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/react': 0.46.2(patch_hash=9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@blocknote/core': 0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/react': 0.46.2(patch_hash=67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@mantine/core': 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@mantine/hooks': 8.3.14(react@19.2.4)
|
||||
'@mantine/utils': 6.0.22(react@19.2.4)
|
||||
@@ -6301,9 +6296,9 @@ snapshots:
|
||||
- sugar-high
|
||||
- supports-color
|
||||
|
||||
'@blocknote/react@0.46.2(patch_hash=9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
'@blocknote/react@0.46.2(patch_hash=67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@blocknote/core': 0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/core': 0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@emoji-mart/data': 1.2.1
|
||||
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@floating-ui/utils': 0.2.10
|
||||
@@ -10519,10 +10514,6 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lucide-react@0.564.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
lz-string@1.5.0: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
|
||||
17
release-notes/v2026-05-14.md
Normal file
17
release-notes/v2026-05-14.md
Normal file
@@ -0,0 +1,17 @@
|
||||
## New Features
|
||||
|
||||
- 🗂️ **Custom Vault Order** — Reorder your vaults so the switcher and workspace controls match the way you actually work.
|
||||
- 🔌 **Smarter External MCP Setup** — Register Tolaria’s external MCP server with dynamic vault resolution, including mounted-workspace guidance for AppImage users.
|
||||
|
||||
## Improvements
|
||||
|
||||
- ⚡ **Faster Large-Note Opens** — Warm parsed editor blocks ahead of note switches so large notes feel more responsive.
|
||||
- 🧭 **Clearer Vault and Status Controls** — Use cleaner status-bar sections, vault menus, and workspace settings rows when managing multiple vaults.
|
||||
- 🐧 **Stronger Linux AppImage Support** — Improve AppImage launch, packaging, FUSE, fcitx input, and MCP subprocess handling across Linux setups.
|
||||
- 🎨 **More Consistent Interface Icons** — Standardize icons on the Phosphor set for a cleaner and more coherent UI.
|
||||
|
||||
## Stability and Fixes
|
||||
|
||||
- This release fixes relationship equality filters, externally moved inbox notes, selected-vault note creation, and duplicate vault folder pickers.
|
||||
- Editor reliability is improved around pulled changes, file attachments, CJK code-block copying, table handles after reload, Mermaid errors, and procedure editor schema recovery.
|
||||
- Whiteboard dialogs, native commit messages, AutoGit author failures, fullscreen navigation, Linux Wayland safeguards, and release history links were hardened.
|
||||
18
release-notes/v2026-05-18.md
Normal file
18
release-notes/v2026-05-18.md
Normal file
@@ -0,0 +1,18 @@
|
||||
## New Features
|
||||
|
||||
- 🔎 **Faster Note-List Search Controls** — Clear note-list searches directly from the list header while keeping keyboard focus and loading feedback predictable.
|
||||
- 🧰 **More Granular Git Controls** — Use clearer Git actions for local commit, pull, push, and sync workflows without collapsing everything into one path.
|
||||
- 🐧 **Linux RPM Download Option** — Stable download pages can expose RPM packages alongside AppImage and other platform installers when release artifacts include them.
|
||||
|
||||
## Improvements
|
||||
|
||||
- 🧭 **Cleaner Update Release Notes Link** — The in-app update banner now opens the public release notes page instead of the old GitHub Pages root.
|
||||
- 📝 **More Reliable Editor Direction and Spacing** — Mixed right-to-left and left-to-right notes resolve text direction per block, and raw-editor line numbers align more cleanly with content.
|
||||
- 🧩 **Better View Filtering for Array Properties** — Saved views now match scalar array properties such as tags more consistently after reloads and across renderer/Rust evaluation.
|
||||
- 🖼️ **Safer Linux AppImage Media Handling** — AppImage builds avoid unstable in-webview audio/video preview paths while preserving stable external-open fallbacks.
|
||||
|
||||
## Stability and Fixes
|
||||
|
||||
- Rich-editor recovery was hardened for invalid list content, embedded attachment paths, file drops, Mermaid reload clicks, aliased wikilinks in Markdown tables, and empty-heading note edits.
|
||||
- Vault and workspace behavior is steadier around mounted default workspaces, app-owned frontmatter reloads, parsed note-list preload warmups, and built-in note body template removal.
|
||||
- Release build type safety, CodeScene thresholds, image-toolbar hover access, and multiple patch-review findings were addressed before promotion.
|
||||
19
release-notes/v2026-05-21.md
Normal file
19
release-notes/v2026-05-21.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## New Features
|
||||
|
||||
- 🧭 **Expanded Note Context Menus** — Right-click notes for faster everyday actions, including opening in a new window, favorites, organization state, Finder reveal, and file path copy.
|
||||
- 🤖 **More Local AI Agent Options** — Use Kiro as a local AI agent, run Tolaria's MCP server through Bun as well as Node, and launch Pi more reliably from shell-managed installs.
|
||||
- 📚 **Portent Knowledge-Base Template** — Tolaria's docs now include Portent as a reference template for structuring durable, agent-friendly knowledge bases.
|
||||
- 🌍 **Belarusian Language Support** — Use Tolaria with new Belarusian interface translations.
|
||||
|
||||
## Improvements
|
||||
|
||||
- ⚡ **Much Faster Note Windows** — Opening a note in a separate window now uses a lighter startup path, avoiding the full main-window load and reducing app-wide stalls.
|
||||
- 🪟 **More Complete Note Actions** — Note-list actions are now available from the context menu as well as existing command paths, making common file and organization tasks easier to reach.
|
||||
- 🧩 **Cleaner CLI Agent Runtime Detection** — Shared binary discovery was consolidated so local agent launches handle shell-managed runtimes more consistently.
|
||||
|
||||
## Stability and Fixes
|
||||
|
||||
- Note windows now open reliably from all entry points and no longer stall the whole application during startup.
|
||||
- Editor reliability is improved around Go code-block highlighting, Mermaid fullscreen zoom, non-Markdown wikilink targets, active-note refresh after external edits, and retained editor memory.
|
||||
- Vault and workspace behavior is steadier around AutoGit multi-vault pushes, new views in the default workspace, and vault watcher Git symlinks.
|
||||
- Release build type safety, pnpm patched dependency handling, CodeScene thresholds, and multiple patch-review findings were addressed before promotion.
|
||||
329
scripts/appimage-launcher-tools.mjs
Normal file
329
scripts/appimage-launcher-tools.mjs
Normal file
@@ -0,0 +1,329 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { error as logError, log } from 'node:console'
|
||||
import { existsSync } from 'node:fs'
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export const BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE =
|
||||
'this_dir="$(readlink -f "$(dirname "$0")")"'
|
||||
export const FIXED_LINUXDEPLOY_APPRUN_DIR_LINE =
|
||||
'this_dir="$(dirname "$(readlink -f "$0")")"'
|
||||
export const APPIMAGE_PLUGIN_WRAPPER_NAME = 'linuxdeploy-plugin-appimage.AppImage'
|
||||
export const REAL_APPIMAGE_PLUGIN_NAME =
|
||||
'tolaria-real-linuxdeploy-plugin-appimage/linuxdeploy-plugin-appimage.AppImage'
|
||||
export const APPIMAGE_FCITX_GTK3_IM_MODULE_PATH =
|
||||
'usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so'
|
||||
export const APPIMAGE_FCITX_GCLIENT_LIBRARY_PATH =
|
||||
'usr/lib/x86_64-linux-gnu/libFcitx5GClient.so.2'
|
||||
export const DEFAULT_APPIMAGE_PLUGIN_URL =
|
||||
'https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage'
|
||||
|
||||
const WRAPPER_MARKER = 'Tolaria AppImage symlink launcher shim'
|
||||
const REQUIRED_APPIMAGE_PATHS = [
|
||||
'AppRun',
|
||||
APPIMAGE_FCITX_GTK3_IM_MODULE_PATH,
|
||||
APPIMAGE_FCITX_GCLIENT_LIBRARY_PATH,
|
||||
]
|
||||
|
||||
export function tauriToolsCacheDir(env = process.env) {
|
||||
if (env.TOLARIA_TAURI_TOOLS_DIR) {
|
||||
return resolve(env.TOLARIA_TAURI_TOOLS_DIR)
|
||||
}
|
||||
|
||||
if (env.XDG_CACHE_HOME) {
|
||||
return resolve(env.XDG_CACHE_HOME, 'tauri')
|
||||
}
|
||||
|
||||
if (!env.HOME) {
|
||||
throw new Error('HOME or XDG_CACHE_HOME is required to locate the Tauri tools cache')
|
||||
}
|
||||
|
||||
return resolve(env.HOME, '.cache', 'tauri')
|
||||
}
|
||||
|
||||
export function patchAppRunText(text) {
|
||||
if (text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE)) {
|
||||
return {
|
||||
changed: true,
|
||||
text: text.replaceAll(
|
||||
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
|
||||
FIXED_LINUXDEPLOY_APPRUN_DIR_LINE,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return { changed: false, text }
|
||||
}
|
||||
|
||||
export function assertSymlinkSafeAppRunText(text, label = 'AppRun') {
|
||||
if (text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE)) {
|
||||
throw new Error(`${label} still resolves dirname before following AppRun symlinks`)
|
||||
}
|
||||
|
||||
if (!text.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE)) {
|
||||
throw new Error(`${label} is missing the symlink-safe AppRun directory resolver`)
|
||||
}
|
||||
}
|
||||
|
||||
export function appImagePluginWrapperSource({
|
||||
pluginUrl = DEFAULT_APPIMAGE_PLUGIN_URL,
|
||||
} = {}) {
|
||||
return `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ${WRAPPER_MARKER}
|
||||
PLUGIN_URL="\${TOLARIA_APPIMAGE_PLUGIN_URL:-${pluginUrl}}"
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
REAL_PLUGIN="\${TOLARIA_APPIMAGE_REAL_PLUGIN:-"$SCRIPT_DIR/${REAL_APPIMAGE_PLUGIN_NAME}"}"
|
||||
FCITX_GTK3_IM_MODULE="\${TOLARIA_FCITX_GTK3_IM_MODULE:-/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so}"
|
||||
FCITX_LIBRARY_DIR="\${TOLARIA_FCITX_LIBRARY_DIR:-/usr/lib/x86_64-linux-gnu}"
|
||||
|
||||
appdir_from_args() {
|
||||
local previous=""
|
||||
|
||||
for arg in "$@"; do
|
||||
if [ "$previous" = "--appdir" ]; then
|
||||
printf '%s\\n' "$arg"
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "$arg" in
|
||||
--appdir=*)
|
||||
printf '%s\\n' "\${arg#--appdir=}"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
previous="$arg"
|
||||
done
|
||||
}
|
||||
|
||||
download_real_plugin() {
|
||||
if [ -x "$REAL_PLUGIN" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local tmp_plugin="$REAL_PLUGIN.tmp.$$"
|
||||
rm -f "$tmp_plugin"
|
||||
mkdir -p "$(dirname -- "$REAL_PLUGIN")"
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL -o "$tmp_plugin" "$PLUGIN_URL"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -q -O "$tmp_plugin" "$PLUGIN_URL"
|
||||
else
|
||||
echo "curl or wget is required to fetch the real linuxdeploy AppImage output plugin" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
chmod +x "$tmp_plugin"
|
||||
mv "$tmp_plugin" "$REAL_PLUGIN"
|
||||
}
|
||||
|
||||
patch_apprun() {
|
||||
local appdir="\${APPDIR:-}"
|
||||
|
||||
if [ -z "$appdir" ]; then
|
||||
appdir="$(appdir_from_args "$@" || true)"
|
||||
fi
|
||||
|
||||
if [ -z "$appdir" ] || [ ! -f "$appdir/AppRun" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
python3 - "$appdir/AppRun" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
broken = 'this_dir="$(readlink -f "$(dirname "$0")")"'
|
||||
fixed = 'this_dir="$(dirname "$(readlink -f "$0")")"'
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
if broken in text:
|
||||
path.write_text(text.replace(broken, fixed), encoding="utf-8")
|
||||
print(f"Patched linuxdeploy AppRun symlink resolution in {path}", file=sys.stderr)
|
||||
elif fixed in text:
|
||||
pass
|
||||
elif "autogenerated by linuxdeploy" in text and "AppRun.wrapped" in text:
|
||||
raise SystemExit(f"{path} is a linuxdeploy wrapper but does not contain the expected AppRun resolver")
|
||||
PY
|
||||
}
|
||||
|
||||
bundle_fcitx_gtk3_module() {
|
||||
local appdir="\${APPDIR:-}"
|
||||
|
||||
if [ -z "$appdir" ]; then
|
||||
appdir="$(appdir_from_args "$@" || true)"
|
||||
fi
|
||||
|
||||
if [ -z "$appdir" ] || [ ! -d "$appdir" ] || [ ! -f "$FCITX_GTK3_IM_MODULE" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local module_dest="$appdir/${APPIMAGE_FCITX_GTK3_IM_MODULE_PATH}"
|
||||
local library_dest_dir="$appdir/usr/lib/x86_64-linux-gnu"
|
||||
|
||||
mkdir -p "$(dirname -- "$module_dest")" "$library_dest_dir"
|
||||
cp -a "$FCITX_GTK3_IM_MODULE" "$module_dest"
|
||||
|
||||
local copied_library=0
|
||||
shopt -s nullglob
|
||||
for lib in "$FCITX_LIBRARY_DIR"/libFcitx5GClient.so* "$FCITX_LIBRARY_DIR"/libFcitx5Utils.so*; do
|
||||
cp -a "$lib" "$library_dest_dir/"
|
||||
copied_library=1
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
if [ "$copied_library" -eq 0 ]; then
|
||||
echo "No fcitx GTK client libraries found in $FCITX_LIBRARY_DIR" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
download_real_plugin
|
||||
patch_apprun "$@"
|
||||
bundle_fcitx_gtk3_module "$@"
|
||||
exec "$REAL_PLUGIN" "$@"
|
||||
`
|
||||
}
|
||||
|
||||
export async function preparePluginWrapper({
|
||||
env = process.env,
|
||||
toolsDir = tauriToolsCacheDir(env),
|
||||
} = {}) {
|
||||
await mkdir(toolsDir, { recursive: true })
|
||||
|
||||
const wrapperPath = join(toolsDir, APPIMAGE_PLUGIN_WRAPPER_NAME)
|
||||
const realPluginPath = join(toolsDir, REAL_APPIMAGE_PLUGIN_NAME)
|
||||
|
||||
if (existsSync(wrapperPath) && !existsSync(realPluginPath)) {
|
||||
const existing = await readFile(wrapperPath, 'utf8').catch(() => '')
|
||||
if (!existing.includes(WRAPPER_MARKER)) {
|
||||
await mkdir(dirname(realPluginPath), { recursive: true })
|
||||
await rename(wrapperPath, realPluginPath)
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(wrapperPath, appImagePluginWrapperSource(), 'utf8')
|
||||
await chmod(wrapperPath, 0o755)
|
||||
|
||||
return { realPluginPath, wrapperPath }
|
||||
}
|
||||
|
||||
export async function validateAppRunFile(path) {
|
||||
const text = await readFile(path, 'utf8')
|
||||
assertSymlinkSafeAppRunText(text, path)
|
||||
}
|
||||
|
||||
function extractAppImagePath(appImage, requiredPath, tempDir) {
|
||||
const result = spawnSync(appImage, ['--appimage-extract', requiredPath], {
|
||||
cwd: tempDir,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
if (result.status === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
`Failed to extract ${requiredPath} from ${appImage}`,
|
||||
result.stdout.trim(),
|
||||
result.stderr.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
function assertAppImagePathsExtracted(appImage, tempDir, requiredPaths) {
|
||||
for (const requiredPath of requiredPaths) {
|
||||
const extractedPath = join(tempDir, 'squashfs-root', requiredPath)
|
||||
if (!existsSync(extractedPath)) {
|
||||
throw new Error(`${appImage} is missing ${requiredPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateExtractedAppImage(appImage, tempDir) {
|
||||
for (const requiredPath of REQUIRED_APPIMAGE_PATHS) {
|
||||
extractAppImagePath(appImage, requiredPath, tempDir)
|
||||
}
|
||||
|
||||
await validateAppRunFile(join(tempDir, 'squashfs-root', 'AppRun'))
|
||||
assertAppImagePathsExtracted(appImage, tempDir, REQUIRED_APPIMAGE_PATHS.slice(1))
|
||||
}
|
||||
|
||||
export async function validateAppImages(paths) {
|
||||
if (paths.length === 0) {
|
||||
throw new Error('At least one AppImage path is required for launcher validation')
|
||||
}
|
||||
|
||||
for (const appImage of paths.map((path) => resolve(path))) {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'tolaria-appimage-'))
|
||||
try {
|
||||
await validateExtractedAppImage(appImage, tempDir)
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function preparePluginCommand() {
|
||||
const { wrapperPath, realPluginPath } = await preparePluginWrapper()
|
||||
log(`Prepared ${wrapperPath}`)
|
||||
log(`Real plugin cache: ${realPluginPath}`)
|
||||
}
|
||||
|
||||
async function validateAppRunFilesCommand(paths) {
|
||||
for (const path of paths) {
|
||||
await validateAppRunFile(path)
|
||||
log(`Validated ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAppImagesCommand(paths) {
|
||||
await validateAppImages(paths)
|
||||
for (const path of paths) {
|
||||
log(`Validated AppImage launcher in ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
const COMMANDS = new Map([
|
||||
['prepare-plugin', preparePluginCommand],
|
||||
['validate-apprun-file', validateAppRunFilesCommand],
|
||||
['validate-appimages', validateAppImagesCommand],
|
||||
])
|
||||
|
||||
function usage() {
|
||||
return 'Usage: node scripts/appimage-launcher-tools.mjs prepare-plugin | validate-apprun-file <AppRun...> | validate-appimages <AppImage...>'
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [command, ...args] = process.argv.slice(2)
|
||||
const handler = COMMANDS.get(command)
|
||||
|
||||
if (!handler) {
|
||||
throw new Error(usage())
|
||||
}
|
||||
|
||||
await handler(args)
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
logError(error.message)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
182
scripts/appimage-launcher-tools.test.mjs
Normal file
182
scripts/appimage-launcher-tools.test.mjs
Normal file
@@ -0,0 +1,182 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, realpathSync } from 'node:fs'
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
|
||||
FIXED_LINUXDEPLOY_APPRUN_DIR_LINE,
|
||||
REAL_APPIMAGE_PLUGIN_NAME,
|
||||
appImagePluginWrapperSource,
|
||||
patchAppRunText,
|
||||
preparePluginWrapper,
|
||||
} from './appimage-launcher-tools.mjs'
|
||||
|
||||
function brokenResolverDir(invokedPath) {
|
||||
return realpathSync(dirname(invokedPath))
|
||||
}
|
||||
|
||||
function fixedResolverDir(invokedPath) {
|
||||
return dirname(realpathSync(invokedPath))
|
||||
}
|
||||
|
||||
test('patches linuxdeploy AppRun wrapper to resolve the invoked path before dirname', () => {
|
||||
const original = [
|
||||
'#! /usr/bin/env bash',
|
||||
'# autogenerated by linuxdeploy',
|
||||
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
|
||||
'exec "$this_dir"/AppRun.wrapped "$@"',
|
||||
].join('\n')
|
||||
|
||||
const patched = patchAppRunText(original)
|
||||
|
||||
assert.equal(patched.changed, true)
|
||||
assert.equal(patched.text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE), false)
|
||||
assert.equal(patched.text.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE), true)
|
||||
})
|
||||
|
||||
test('fixed resolver follows absolute and relative symlinks before choosing AppDir', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'tolaria-apprun-resolver-'))
|
||||
const appDir = join(root, 'Tolaria.AppDir')
|
||||
const binDir = join(root, 'bin')
|
||||
const relativeDir = join(root, 'relative-bin')
|
||||
const appRun = join(appDir, 'AppRun')
|
||||
|
||||
await mkdir(appDir)
|
||||
await mkdir(binDir)
|
||||
await mkdir(relativeDir)
|
||||
await writeFile(appRun, '#! /usr/bin/env bash\n', 'utf8')
|
||||
|
||||
const absoluteSymlink = join(binDir, 'tolaria')
|
||||
const relativeSymlink = join(relativeDir, 'tolaria')
|
||||
|
||||
await symlink(appRun, absoluteSymlink)
|
||||
await symlink(`../${basename(appDir)}/AppRun`, relativeSymlink)
|
||||
|
||||
assert.equal(brokenResolverDir(absoluteSymlink), realpathSync(binDir))
|
||||
assert.equal(fixedResolverDir(absoluteSymlink), realpathSync(appDir))
|
||||
assert.equal(brokenResolverDir(relativeSymlink), realpathSync(relativeDir))
|
||||
assert.equal(fixedResolverDir(relativeSymlink), realpathSync(appDir))
|
||||
})
|
||||
|
||||
test('plugin wrapper patches AppRun before delegating to the real output plugin', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-plugin-'))
|
||||
const appDir = join(root, 'Tolaria.AppDir')
|
||||
const appRun = join(appDir, 'AppRun')
|
||||
const wrapper = join(root, 'linuxdeploy-plugin-appimage.AppImage')
|
||||
const realPlugin = join(root, 'linuxdeploy-plugin-appimage.real.AppImage')
|
||||
const pluginMarker = join(root, 'plugin-ran')
|
||||
|
||||
await mkdir(appDir)
|
||||
await writeFile(
|
||||
appRun,
|
||||
[
|
||||
'#! /usr/bin/env bash',
|
||||
'# autogenerated by linuxdeploy',
|
||||
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
|
||||
'exec "$this_dir"/AppRun.wrapped "$@"',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
)
|
||||
await writeFile(wrapper, appImagePluginWrapperSource(), 'utf8')
|
||||
await chmod(wrapper, 0o755)
|
||||
await writeFile(
|
||||
realPlugin,
|
||||
`#!/usr/bin/env bash\nset -euo pipefail\ntouch "${pluginMarker}"\n`,
|
||||
'utf8',
|
||||
)
|
||||
await chmod(realPlugin, 0o755)
|
||||
|
||||
const result = spawnSync(wrapper, [], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
APPDIR: appDir,
|
||||
TOLARIA_APPIMAGE_REAL_PLUGIN: realPlugin,
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
assert.equal(existsSync(pluginMarker), true)
|
||||
|
||||
const patched = await readFile(appRun, 'utf8')
|
||||
assert.equal(patched.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE), false)
|
||||
assert.equal(patched.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE), true)
|
||||
})
|
||||
|
||||
test('plugin wrapper keeps the delegated appimage plugin basename canonical', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-tools-'))
|
||||
const { realPluginPath, wrapperPath } = await preparePluginWrapper({
|
||||
toolsDir: root,
|
||||
})
|
||||
|
||||
assert.equal(basename(wrapperPath), 'linuxdeploy-plugin-appimage.AppImage')
|
||||
assert.equal(
|
||||
realPluginPath,
|
||||
join(root, 'tolaria-real-linuxdeploy-plugin-appimage', 'linuxdeploy-plugin-appimage.AppImage'),
|
||||
)
|
||||
assert.equal(REAL_APPIMAGE_PLUGIN_NAME.endsWith('/linuxdeploy-plugin-appimage.AppImage'), true)
|
||||
|
||||
const wrapper = await readFile(wrapperPath, 'utf8')
|
||||
assert.equal(wrapper.includes('linuxdeploy-plugin-appimage.real.AppImage'), false)
|
||||
})
|
||||
|
||||
test('plugin wrapper bundles fcitx GTK3 input module before sealing AppImage', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-fcitx-'))
|
||||
const appDir = join(root, 'Tolaria.AppDir')
|
||||
const wrapper = join(root, 'linuxdeploy-plugin-appimage.AppImage')
|
||||
const realPlugin = join(root, 'linuxdeploy-plugin-appimage.real.AppImage')
|
||||
const pluginMarker = join(root, 'plugin-ran')
|
||||
const hostModule = join(root, 'host', 'im-fcitx5.so')
|
||||
const hostLibraryDir = join(root, 'host-lib')
|
||||
const hostLibrary = join(hostLibraryDir, 'libFcitx5GClient.so.2')
|
||||
|
||||
await mkdir(appDir)
|
||||
await mkdir(dirname(hostModule), { recursive: true })
|
||||
await mkdir(hostLibraryDir)
|
||||
await writeFile(hostModule, 'fake fcitx gtk module', 'utf8')
|
||||
await writeFile(hostLibrary, 'fake fcitx client library', 'utf8')
|
||||
await writeFile(wrapper, appImagePluginWrapperSource(), 'utf8')
|
||||
await chmod(wrapper, 0o755)
|
||||
await writeFile(
|
||||
realPlugin,
|
||||
`#!/usr/bin/env bash\nset -euo pipefail\ntouch "${pluginMarker}"\n`,
|
||||
'utf8',
|
||||
)
|
||||
await chmod(realPlugin, 0o755)
|
||||
|
||||
const result = spawnSync(wrapper, [], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
APPDIR: appDir,
|
||||
TOLARIA_APPIMAGE_REAL_PLUGIN: realPlugin,
|
||||
TOLARIA_FCITX_GTK3_IM_MODULE: hostModule,
|
||||
TOLARIA_FCITX_LIBRARY_DIR: hostLibraryDir,
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
assert.equal(existsSync(pluginMarker), true)
|
||||
assert.equal(
|
||||
existsSync(
|
||||
join(
|
||||
appDir,
|
||||
'usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so',
|
||||
),
|
||||
),
|
||||
true,
|
||||
)
|
||||
assert.equal(existsSync(join(appDir, 'usr/lib/x86_64-linux-gnu/libFcitx5GClient.so.2')), true)
|
||||
})
|
||||
@@ -6,7 +6,7 @@ const repoRoot = path.resolve(import.meta.dirname, '..')
|
||||
const siteRoot = path.join(repoRoot, 'site')
|
||||
const outputRoot = path.join(repoRoot, 'src-tauri', 'resources', 'agent-docs')
|
||||
|
||||
const sectionOrder = ['start', 'concepts', 'guides', 'reference', 'troubleshooting', 'download', 'releases']
|
||||
const sectionOrder = ['start', 'concepts', 'guides', 'templates', 'reference', 'troubleshooting', 'download', 'releases']
|
||||
const ignoredDirs = new Set(['.vitepress', 'public', 'node_modules', '.DS_Store'])
|
||||
|
||||
function titleFromSlug(slug) {
|
||||
@@ -90,6 +90,8 @@ function buildIndex(docs) {
|
||||
'',
|
||||
'Start here, then use `rg` over this folder for specific Tolaria concepts and workflows.',
|
||||
'',
|
||||
'When users ask how to improve a knowledge base, structure notes, choose types, model relationships, or make a vault easier for humans and agents to use, treat Portent as Tolaria\'s default best-practice model. Read `pages/templates/portent.md` and combine it with the Tolaria concepts for types, relationships, properties, Inbox, archive, and custom views.',
|
||||
'',
|
||||
]
|
||||
|
||||
for (const section of ['home', ...sectionOrder]) {
|
||||
@@ -120,11 +122,13 @@ This folder contains local, generated Tolaria product docs for AI agents.
|
||||
|
||||
Use these docs when a user asks how Tolaria works, when you need product behavior, or before making Tolaria-specific assumptions.
|
||||
|
||||
Portent is the default best-practice model for structuring Tolaria knowledge bases. When a user asks how to improve a knowledge base, make it better organized, choose better types, model relationships, or make the vault easier for humans and agents to use, read \`pages/templates/portent.md\` and combine it with Tolaria's docs for types, relationships, properties, Inbox, archive, and custom views.
|
||||
|
||||
Recommended lookup flow:
|
||||
|
||||
1. Read the active vault's AGENTS.md for vault-specific conventions.
|
||||
2. Read this folder's index.md for the docs map.
|
||||
3. Use \`rg\` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, types, properties, relationships, and troubleshooting.
|
||||
3. Use \`rg\` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, Portent, types, properties, relationships, and troubleshooting.
|
||||
|
||||
Vault-specific AGENTS.md wins for local conventions. These bundled docs win for Tolaria product behavior.
|
||||
`
|
||||
|
||||
@@ -17,12 +17,16 @@ const hasMaxWorkersOverride = forwardedArgs.some((arg) =>
|
||||
)
|
||||
const maxAttempts = 2
|
||||
|
||||
// Standalone pnpm installs ship a native binary, so npm_execpath points at
|
||||
// a Mach-O/ELF executable. Only reuse process.execPath (node) when the path
|
||||
// is something node can actually load as a module.
|
||||
const packageManagerExec = process.env.npm_execpath
|
||||
const command = packageManagerExec ? process.execPath : 'pnpm'
|
||||
const baseCommandArgs = packageManagerExec
|
||||
const isJsExecpath = packageManagerExec && /\.[mc]?js$/i.test(packageManagerExec)
|
||||
const command = isJsExecpath ? process.execPath : 'pnpm'
|
||||
const baseCommandArgs = isJsExecpath
|
||||
? [packageManagerExec, 'exec', 'vitest', 'run', '--coverage']
|
||||
: ['exec', 'vitest', 'run', '--coverage']
|
||||
const clearCacheCommandArgs = packageManagerExec
|
||||
const clearCacheCommandArgs = isJsExecpath
|
||||
? [packageManagerExec, 'exec', 'vitest', '--clearCache']
|
||||
: ['exec', 'vitest', '--clearCache']
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export default defineConfig({
|
||||
{ text: "Start", link: "/start/install" },
|
||||
{ text: "Concepts", link: "/concepts/vaults" },
|
||||
{ text: "Guides", link: "/guides/capture-a-note" },
|
||||
{ text: "Templates", link: "/templates/portent" },
|
||||
{ text: "Downloads", link: "https://tolaria.md/download/", target: "_self", noIcon: true },
|
||||
],
|
||||
search: {
|
||||
@@ -75,6 +76,12 @@ export default defineConfig({
|
||||
{ text: "Use The Command Palette", link: "/guides/use-command-palette" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Templates",
|
||||
items: [
|
||||
{ text: "Portent", link: "/templates/portent" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Reference",
|
||||
items: [
|
||||
|
||||
@@ -6,7 +6,7 @@ Tolaria is a desktop app built with Tauri. Releases currently target macOS, Wind
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
|
||||
## Support Policy
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ brew install --cask tolaria
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
|
||||
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
|
||||
|
||||
|
||||
72
site/templates/portent.md
Normal file
72
site/templates/portent.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Portent
|
||||
|
||||
[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases.
|
||||
|
||||
It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first.
|
||||
|
||||
## Core Questions
|
||||
|
||||
Portent favors convention over configuration. Instead of asking "where should this go?", it asks:
|
||||
|
||||
- What is this?
|
||||
- What is it useful for?
|
||||
- Is it captured, organized, or archived?
|
||||
|
||||
Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views.
|
||||
|
||||
## Types
|
||||
|
||||
Portent defines eight default types.
|
||||
|
||||
PORT types are actionable:
|
||||
|
||||
- Project
|
||||
- Operation
|
||||
- Responsibility
|
||||
- Task
|
||||
|
||||
ENTP types are non-actionable knowledge records:
|
||||
|
||||
- Event
|
||||
- Note
|
||||
- Topic
|
||||
- Person
|
||||
|
||||
These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first.
|
||||
|
||||
## Relationships
|
||||
|
||||
Portent models knowledge as a graph. The two default relationships are:
|
||||
|
||||
- `belongs_to`: primary ownership, composition, or context.
|
||||
- `related_to`: a looser semantic connection.
|
||||
|
||||
In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Portent separates capture from organization:
|
||||
|
||||
1. Capture information quickly so it is not lost.
|
||||
2. Organize it by assigning a type and useful relationships.
|
||||
3. Archive it when it has served its purpose.
|
||||
|
||||
Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available.
|
||||
|
||||
## Why Use It
|
||||
|
||||
A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately.
|
||||
|
||||
Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals.
|
||||
|
||||
## Start From The Template
|
||||
|
||||
The fastest starting point is the Portent template vault:
|
||||
|
||||
- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template)
|
||||
|
||||
Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup.
|
||||
|
||||
## Learn More
|
||||
|
||||
Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes.
|
||||
60
src-tauri/Cargo.lock
generated
60
src-tauri/Cargo.lock
generated
@@ -470,6 +470,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -1934,7 +1940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2051,6 +2057,19 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -2469,6 +2488,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.17.1"
|
||||
@@ -2484,7 +2513,7 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
@@ -3250,6 +3279,19 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -3392,6 +3434,12 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -4742,6 +4790,7 @@ dependencies = [
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http-range",
|
||||
"image",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4809,7 +4858,7 @@ dependencies = [
|
||||
"ico",
|
||||
"json-patch",
|
||||
"plist",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
@@ -5290,6 +5339,9 @@ dependencies = [
|
||||
"gray_matter",
|
||||
"log",
|
||||
"notify",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"sentry",
|
||||
@@ -5517,7 +5569,7 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
|
||||
@@ -21,7 +21,7 @@ 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 = { version = "2.10.0", features = ["protocol-asset", "devtools", "image-png"] }
|
||||
tauri-plugin-log = "2"
|
||||
gray_matter = "0.2"
|
||||
walkdir = "2"
|
||||
@@ -41,4 +41,9 @@ uuid = { version = "1", features = ["v4"] }
|
||||
tempfile = "3"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSImage"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSData", "NSThread"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
BIN
src-tauri/icons/512x512-dark.png
Normal file
BIN
src-tauri/icons/512x512-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
41
src-tauri/icons/icon-source-dark.svg
Normal file
41
src-tauri/icons/icon-source-dark.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_110_75)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M924 356.627C924 346.845 924.004 337.062 923.944 327.279C923.895 319.038 923.8 310.799 923.576 302.562C923.092 284.609 922.033 266.502 918.84 248.749C915.602 230.741 910.314 213.98 901.981 197.617C893.789 181.534 883.088 166.817 870.32 154.058C857.555 141.299 842.834 130.605 826.746 122.418C810.366 114.083 793.587 108.797 775.558 105.56C757.803 102.372 739.691 101.315 721.738 100.83C713.495 100.607 705.253 100.513 697.008 100.462C687.22 100.402 677.432 100.407 667.644 100.407L553.997 100H468.997L357.361 100.407C347.554 100.407 337.747 100.402 327.94 100.462C319.678 100.513 311.42 100.607 303.161 100.83C285.167 101.315 267.014 102.373 249.217 105.565C231.164 108.801 214.36 114.085 197.958 122.414C181.835 130.602 167.083 141.297 154.291 154.058C141.501 166.816 130.78 181.53 122.573 197.61C114.217 213.981 108.919 230.752 105.673 248.77C102.477 266.516 101.418 284.617 100.931 302.562C100.709 310.8 100.613 319.039 100.563 327.279C100.503 337.063 100 349.216 100 358.999L100.003 469.089L100 554.998L100.508 667.427C100.508 677.223 100.504 687.019 100.563 696.815C100.613 705.067 100.709 713.317 100.932 721.566C101.418 739.542 102.479 757.675 105.678 775.452C108.923 793.484 114.22 810.269 122.569 826.653C130.777 842.759 141.5 857.495 154.291 870.272C167.082 883.049 181.83 893.757 197.95 901.956C214.362 910.302 231.174 915.595 249.238 918.836C267.027 922.029 285.174 923.088 303.161 923.573C311.42 923.796 319.679 923.891 327.941 923.941C337.748 924.001 347.554 923.997 357.361 923.997L470.006 924H555.217L667.644 923.996C677.432 923.996 687.22 924.001 697.008 923.941C705.253 923.891 713.495 923.796 721.738 923.573C739.698 923.087 757.816 922.027 775.579 918.832C793.597 915.591 810.368 910.3 826.739 901.959C842.831 893.761 857.554 883.051 870.32 870.272C883.086 857.497 893.786 842.763 901.978 826.66C910.316 810.268 915.604 793.475 918.844 775.431C922.034 757.661 923.092 739.535 923.577 721.566C923.8 713.316 923.895 705.066 923.944 696.815C924.005 687.019 924 677.223 924 667.427C924 667.427 923.994 556.983 923.994 554.998V468.999C923.994 467.533 924 356.627 924 356.627Z" fill="#151923"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M924 356.627C924 346.845 924.004 337.062 923.944 327.279C923.895 319.038 923.8 310.799 923.576 302.562C923.092 284.609 922.033 266.502 918.84 248.749C915.602 230.741 910.314 213.98 901.981 197.617C893.789 181.534 883.088 166.817 870.32 154.058C857.555 141.299 842.834 130.605 826.746 122.418C810.366 114.083 793.587 108.797 775.558 105.56C757.803 102.372 739.691 101.315 721.738 100.83C713.495 100.607 705.253 100.513 697.008 100.462C687.22 100.402 677.432 100.407 667.644 100.407L553.997 100H468.997L357.361 100.407C347.554 100.407 337.747 100.402 327.94 100.462C319.678 100.513 311.42 100.607 303.161 100.83C285.167 101.315 267.014 102.373 249.217 105.565C231.164 108.801 214.36 114.085 197.958 122.414C181.835 130.602 167.083 141.297 154.291 154.058C141.501 166.816 130.78 181.53 122.573 197.61C114.217 213.981 108.919 230.752 105.673 248.77C102.477 266.516 101.418 284.617 100.931 302.562C100.709 310.8 100.613 319.039 100.563 327.279C100.503 337.063 100 349.216 100 358.999L100.003 469.089L100 554.998L100.508 667.427C100.508 677.223 100.504 687.019 100.563 696.815C100.613 705.067 100.709 713.317 100.932 721.566C101.418 739.542 102.479 757.675 105.678 775.452C108.923 793.484 114.22 810.269 122.569 826.653C130.777 842.759 141.5 857.495 154.291 870.272C167.082 883.049 181.83 893.757 197.95 901.956C214.362 910.302 231.174 915.595 249.238 918.836C267.027 922.029 285.174 923.088 303.161 923.573C311.42 923.796 319.679 923.891 327.941 923.941C337.748 924.001 347.554 923.997 357.361 923.997L470.006 924H555.217L667.644 923.996C677.432 923.996 687.22 924.001 697.008 923.941C705.253 923.891 713.495 923.796 721.738 923.573C739.698 923.087 757.816 922.027 775.579 918.832C793.597 915.591 810.368 910.3 826.739 901.959C842.831 893.761 857.554 883.051 870.32 870.272C883.086 857.497 893.786 842.763 901.978 826.66C910.316 810.268 915.604 793.475 918.844 775.431C922.034 757.661 923.092 739.535 923.577 721.566C923.8 713.316 923.895 705.066 923.944 696.815C924.005 687.019 924 677.223 924 667.427C924 667.427 923.994 556.983 923.994 554.998V468.999C923.994 467.533 924 356.627 924 356.627Z" fill="url(#paint0_linear_110_75)" fill-opacity="0.42"/>
|
||||
</g>
|
||||
<mask id="mask0_110_75" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="100" y="100" width="824" height="824">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M924 356.627C924 346.845 924.004 337.062 923.944 327.279C923.895 319.038 923.8 310.799 923.576 302.562C923.092 284.609 922.033 266.502 918.84 248.749C915.602 230.741 910.314 213.98 901.981 197.617C893.789 181.534 883.088 166.817 870.32 154.058C857.555 141.299 842.834 130.605 826.746 122.418C810.366 114.083 793.587 108.797 775.558 105.56C757.803 102.372 739.691 101.315 721.738 100.83C713.495 100.607 705.253 100.513 697.008 100.462C687.22 100.402 677.432 100.407 667.644 100.407L553.997 100H468.997L357.361 100.407C347.554 100.407 337.747 100.402 327.94 100.462C319.678 100.513 311.42 100.607 303.161 100.83C285.167 101.315 267.014 102.373 249.217 105.565C231.164 108.801 214.36 114.085 197.958 122.414C181.835 130.602 167.083 141.297 154.291 154.058C141.501 166.816 130.78 181.53 122.573 197.61C114.217 213.981 108.919 230.752 105.673 248.77C102.477 266.516 101.418 284.617 100.931 302.562C100.709 310.8 100.613 319.039 100.563 327.279C100.503 337.063 100 349.216 100 358.999L100.003 469.089L100 554.998L100.508 667.427C100.508 677.223 100.504 687.019 100.563 696.815C100.613 705.067 100.709 713.317 100.932 721.566C101.418 739.542 102.479 757.675 105.678 775.452C108.923 793.484 114.22 810.269 122.569 826.653C130.777 842.759 141.5 857.495 154.291 870.272C167.082 883.049 181.83 893.757 197.95 901.956C214.362 910.302 231.174 915.595 249.238 918.836C267.027 922.029 285.174 923.088 303.161 923.573C311.42 923.796 319.679 923.891 327.941 923.941C337.748 924.001 347.554 923.997 357.361 923.997L470.006 924H555.217L667.644 923.996C677.432 923.996 687.22 924.001 697.008 923.941C705.253 923.891 713.495 923.796 721.738 923.573C739.698 923.087 757.816 922.027 775.579 918.832C793.597 915.591 810.368 910.3 826.739 901.959C842.831 893.761 857.554 883.051 870.32 870.272C883.086 857.497 893.786 842.763 901.978 826.66C910.316 810.268 915.604 793.475 918.844 775.431C922.034 757.661 923.092 739.535 923.577 721.566C923.8 713.316 923.895 705.066 923.944 696.815C924.005 687.019 924 677.223 924 667.427C924 667.427 923.994 556.983 923.994 554.998V468.999C923.994 467.533 924 356.627 924 356.627Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_110_75)">
|
||||
</g>
|
||||
<g opacity="0.4" filter="url(#filter1_f_110_75)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M491.45 246.005C502.143 235.332 519.52 235.332 530.213 246.005C581.007 299.371 712 460.802 712 584.878C712 695.612 622.443 785 511.5 785C400.557 785 311 695.612 311 584.878C311 460.802 441.993 299.371 491.45 246.005ZM400.972 603.993C398.809 591.733 387.083 583.189 374.74 584.878C362.397 586.568 354.168 597.843 356.331 610.103C367.894 675.638 420.114 728.676 486.27 742.031C488.878 742.56 491.491 742.603 493.947 742.267C503.142 741.008 510.721 734.157 512.354 724.625C514.445 712.509 506.134 700.696 493.807 698.205C446.539 688.645 409.226 650.772 400.972 603.993Z" fill="#155DFF"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M400.972 603.993C398.809 591.733 387.083 583.189 374.74 584.878C362.397 586.568 354.168 597.843 356.331 610.103C367.894 675.638 420.114 728.676 486.27 742.031C488.878 742.56 491.491 742.603 493.947 742.267C503.142 741.008 510.721 734.157 512.354 724.625C514.445 712.509 506.134 700.696 493.807 698.205C446.539 688.645 409.226 650.772 400.972 603.993Z" fill="#C4E4FF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M491.45 246.005C502.143 235.332 519.52 235.332 530.213 246.005C581.007 299.371 712 460.802 712 584.878C712 695.612 622.443 785 511.5 785C400.557 785 311 695.612 311 584.878C311 460.802 441.993 299.371 491.45 246.005ZM400.972 603.993C398.809 591.733 387.083 583.189 374.74 584.878C362.397 586.568 354.168 597.843 356.331 610.103C367.894 675.638 420.114 728.676 486.27 742.031C488.878 742.56 491.491 742.603 493.947 742.267C503.142 741.008 510.721 734.157 512.354 724.625C514.445 712.509 506.134 700.696 493.807 698.205C446.539 688.645 409.226 650.772 400.972 603.993Z" fill="#155DFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M491.45 246.005C502.143 235.332 519.52 235.332 530.213 246.005C581.007 299.371 712 460.802 712 584.878C712 695.612 622.443 785 511.5 785C400.557 785 311 695.612 311 584.878C311 460.802 441.993 299.371 491.45 246.005ZM400.972 603.993C398.809 591.733 387.083 583.189 374.74 584.878C362.397 586.568 354.168 597.843 356.331 610.103C367.894 675.638 420.114 728.676 486.27 742.031C488.878 742.56 491.491 742.603 493.947 742.267C503.142 741.008 510.721 734.157 512.354 724.625C514.445 712.509 506.134 700.696 493.807 698.205C446.539 688.645 409.226 650.772 400.972 603.993Z" fill="url(#paint1_linear_110_75)"/>
|
||||
<defs>
|
||||
<filter id="filter0_d_110_75" x="75" y="80" width="884" height="884" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dx="5" dy="10"/>
|
||||
<feGaussianBlur stdDeviation="15"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_110_75"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_110_75" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_110_75" x="281" y="208" width="461" height="607" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="15" result="effect1_foregroundBlur_110_75"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_110_75" x1="233" y1="138.5" x2="784.5" y2="1320.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2D3546"/>
|
||||
<stop offset="1" stop-color="#070A10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_110_75" x1="126" y1="-35.5" x2="723" y2="744" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00AEFF"/>
|
||||
<stop offset="1" stop-color="#155DFF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -4,10 +4,12 @@ This folder contains local, generated Tolaria product docs for AI agents.
|
||||
|
||||
Use these docs when a user asks how Tolaria works, when you need product behavior, or before making Tolaria-specific assumptions.
|
||||
|
||||
Portent is the default best-practice model for structuring Tolaria knowledge bases. When a user asks how to improve a knowledge base, make it better organized, choose better types, model relationships, or make the vault easier for humans and agents to use, read `pages/templates/portent.md` and combine it with Tolaria's docs for types, relationships, properties, Inbox, archive, and custom views.
|
||||
|
||||
Recommended lookup flow:
|
||||
|
||||
1. Read the active vault's AGENTS.md for vault-specific conventions.
|
||||
2. Read this folder's index.md for the docs map.
|
||||
3. Use `rg` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, types, properties, relationships, and troubleshooting.
|
||||
3. Use `rg` over this folder for advanced concepts, workflows, shortcuts, Git, AutoGit, AI, Portent, types, properties, relationships, and troubleshooting.
|
||||
|
||||
Vault-specific AGENTS.md wins for local conventions. These bundled docs win for Tolaria product behavior.
|
||||
|
||||
@@ -121,7 +121,7 @@ brew install --cask tolaria
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
|
||||
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
|
||||
|
||||
@@ -1023,6 +1023,86 @@ Prefer clear note titles and filenames. Tolaria's wikilink autocomplete helps yo
|
||||
|
||||
---
|
||||
|
||||
# Portent
|
||||
|
||||
Source: templates/portent.md
|
||||
URL: /templates/portent
|
||||
|
||||
# Portent
|
||||
|
||||
[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases.
|
||||
|
||||
It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first.
|
||||
|
||||
## Core Questions
|
||||
|
||||
Portent favors convention over configuration. Instead of asking "where should this go?", it asks:
|
||||
|
||||
- What is this?
|
||||
- What is it useful for?
|
||||
- Is it captured, organized, or archived?
|
||||
|
||||
Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views.
|
||||
|
||||
## Types
|
||||
|
||||
Portent defines eight default types.
|
||||
|
||||
PORT types are actionable:
|
||||
|
||||
- Project
|
||||
- Operation
|
||||
- Responsibility
|
||||
- Task
|
||||
|
||||
ENTP types are non-actionable knowledge records:
|
||||
|
||||
- Event
|
||||
- Note
|
||||
- Topic
|
||||
- Person
|
||||
|
||||
These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first.
|
||||
|
||||
## Relationships
|
||||
|
||||
Portent models knowledge as a graph. The two default relationships are:
|
||||
|
||||
- `belongs_to`: primary ownership, composition, or context.
|
||||
- `related_to`: a looser semantic connection.
|
||||
|
||||
In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Portent separates capture from organization:
|
||||
|
||||
1. Capture information quickly so it is not lost.
|
||||
2. Organize it by assigning a type and useful relationships.
|
||||
3. Archive it when it has served its purpose.
|
||||
|
||||
Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available.
|
||||
|
||||
## Why Use It
|
||||
|
||||
A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately.
|
||||
|
||||
Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals.
|
||||
|
||||
## Start From The Template
|
||||
|
||||
The fastest starting point is the Portent template vault:
|
||||
|
||||
- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template)
|
||||
|
||||
Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup.
|
||||
|
||||
## Learn More
|
||||
|
||||
Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes.
|
||||
|
||||
---
|
||||
|
||||
# Contribute
|
||||
|
||||
Source: reference/contribute.md
|
||||
@@ -1272,7 +1352,7 @@ Tolaria is a desktop app built with Tauri. Releases currently target macOS, Wind
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
|
||||
## Support Policy
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ These docs are generated from the public Tolaria documentation for local AI agen
|
||||
|
||||
Start here, then use `rg` over this folder for specific Tolaria concepts and workflows.
|
||||
|
||||
When users ask how to improve a knowledge base, structure notes, choose types, model relationships, or make a vault easier for humans and agents to use, treat Portent as Tolaria's default best-practice model. Read `pages/templates/portent.md` and combine it with the Tolaria concepts for types, relationships, properties, Inbox, archive, and custom views.
|
||||
|
||||
## Home
|
||||
|
||||
- [Index](pages/index.md)
|
||||
@@ -44,6 +46,10 @@ Start here, then use `rg` over this folder for specific Tolaria concepts and wor
|
||||
- [Use The Table Of Contents](pages/guides/use-table-of-contents.md)
|
||||
- [Use Wikilinks](pages/guides/use-wikilinks.md)
|
||||
|
||||
## Templates
|
||||
|
||||
- [Portent](pages/templates/portent.md)
|
||||
|
||||
## Reference
|
||||
|
||||
- [Contribute](pages/reference/contribute.md)
|
||||
|
||||
@@ -11,7 +11,7 @@ Tolaria is a desktop app built with Tauri. Releases currently target macOS, Wind
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
|
||||
## Support Policy
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ brew install --cask tolaria
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
|
||||
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
|
||||
|
||||
|
||||
77
src-tauri/resources/agent-docs/pages/templates/portent.md
Normal file
77
src-tauri/resources/agent-docs/pages/templates/portent.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Portent
|
||||
|
||||
Source: templates/portent.md
|
||||
URL: /templates/portent
|
||||
|
||||
# Portent
|
||||
|
||||
[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases.
|
||||
|
||||
It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first.
|
||||
|
||||
## Core Questions
|
||||
|
||||
Portent favors convention over configuration. Instead of asking "where should this go?", it asks:
|
||||
|
||||
- What is this?
|
||||
- What is it useful for?
|
||||
- Is it captured, organized, or archived?
|
||||
|
||||
Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views.
|
||||
|
||||
## Types
|
||||
|
||||
Portent defines eight default types.
|
||||
|
||||
PORT types are actionable:
|
||||
|
||||
- Project
|
||||
- Operation
|
||||
- Responsibility
|
||||
- Task
|
||||
|
||||
ENTP types are non-actionable knowledge records:
|
||||
|
||||
- Event
|
||||
- Note
|
||||
- Topic
|
||||
- Person
|
||||
|
||||
These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first.
|
||||
|
||||
## Relationships
|
||||
|
||||
Portent models knowledge as a graph. The two default relationships are:
|
||||
|
||||
- `belongs_to`: primary ownership, composition, or context.
|
||||
- `related_to`: a looser semantic connection.
|
||||
|
||||
In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Portent separates capture from organization:
|
||||
|
||||
1. Capture information quickly so it is not lost.
|
||||
2. Organize it by assigning a type and useful relationships.
|
||||
3. Archive it when it has served its purpose.
|
||||
|
||||
Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available.
|
||||
|
||||
## Why Use It
|
||||
|
||||
A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately.
|
||||
|
||||
Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals.
|
||||
|
||||
## Start From The Template
|
||||
|
||||
The fastest starting point is the Portent template vault:
|
||||
|
||||
- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template)
|
||||
|
||||
Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup.
|
||||
|
||||
## Learn More
|
||||
|
||||
Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes.
|
||||
@@ -247,7 +247,7 @@ Tolaria is a desktop app built with Tauri. Releases currently target macOS, Wind
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Main development and QA target. Apple Silicon and Intel artifacts are published. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Menu, shell-path, and credential-helper behavior receive platform-specific fixes as they appear. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Behavior can depend on distro WebKitGTK packages, Wayland/X11 details, and input-method setup. |
|
||||
|
||||
## Support Policy
|
||||
|
||||
|
||||
@@ -323,6 +323,21 @@
|
||||
"Keep Links Stable"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Portent",
|
||||
"path": "pages/templates/portent.md",
|
||||
"url": "/templates/portent",
|
||||
"section": "templates",
|
||||
"headings": [
|
||||
"Core Questions",
|
||||
"Types",
|
||||
"Relationships",
|
||||
"Lifecycle",
|
||||
"Why Use It",
|
||||
"Start From The Template",
|
||||
"Learn More"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Contribute",
|
||||
"path": "pages/reference/contribute.md",
|
||||
|
||||
@@ -112,7 +112,7 @@ brew install --cask tolaria
|
||||
| --- | --- | --- |
|
||||
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
|
||||
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
|
||||
| Linux | Supported, early | AppImage and deb artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
|
||||
|
||||
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
|
||||
|
||||
|
||||
77
src-tauri/resources/agent-docs/templates.md
Normal file
77
src-tauri/resources/agent-docs/templates.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Portent
|
||||
|
||||
Source: templates/portent.md
|
||||
URL: /templates/portent
|
||||
|
||||
# Portent
|
||||
|
||||
[Portent](https://portent.md) is an open specification and template for work and personal knowledge bases.
|
||||
|
||||
It gives a Tolaria vault a small set of defaults for organizing information: clear types, generic graph-like relationships, and a simple lifecycle for captured knowledge. The goal is to make a knowledge base useful to humans and AI agents without forcing every person or team to design a private ontology first.
|
||||
|
||||
## Core Questions
|
||||
|
||||
Portent favors convention over configuration. Instead of asking "where should this go?", it asks:
|
||||
|
||||
- What is this?
|
||||
- What is it useful for?
|
||||
- Is it captured, organized, or archived?
|
||||
|
||||
Those questions map naturally to Tolaria's type documents, relationship fields, Inbox, organized state, archive behavior, and custom views.
|
||||
|
||||
## Types
|
||||
|
||||
Portent defines eight default types.
|
||||
|
||||
PORT types are actionable:
|
||||
|
||||
- Project
|
||||
- Operation
|
||||
- Responsibility
|
||||
- Task
|
||||
|
||||
ENTP types are non-actionable knowledge records:
|
||||
|
||||
- Event
|
||||
- Note
|
||||
- Topic
|
||||
- Person
|
||||
|
||||
These defaults are meant to cover the common shape of personal and work knowledge with almost no setup. You can add custom types later, but Portent works best when the default vocabulary comes first.
|
||||
|
||||
## Relationships
|
||||
|
||||
Portent models knowledge as a graph. The two default relationships are:
|
||||
|
||||
- `belongs_to`: primary ownership, composition, or context.
|
||||
- `related_to`: a looser semantic connection.
|
||||
|
||||
In Tolaria, these relationships can live in YAML frontmatter and point to other notes with wikilinks. That keeps the graph portable, searchable, and readable outside the app.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Portent separates capture from organization:
|
||||
|
||||
1. Capture information quickly so it is not lost.
|
||||
2. Organize it by assigning a type and useful relationships.
|
||||
3. Archive it when it has served its purpose.
|
||||
|
||||
Tolaria supports that lifecycle directly: the Inbox holds captured notes, organizing a note marks it ready for normal views, and archiving hides old or obsolete notes from active surfaces while keeping them available.
|
||||
|
||||
## Why Use It
|
||||
|
||||
A blank vault is flexible, but it also asks you to make structural decisions before you have momentum. Portent gives you enough structure to start capturing, organizing, and retrieving notes immediately.
|
||||
|
||||
Because Portent is file-friendly and portable, the same model can work across local Markdown vaults, note apps, docs tools, and agent-readable knowledge bases. Tolaria is the first intended implementation, but the spec is not tied to Tolaria internals.
|
||||
|
||||
## Start From The Template
|
||||
|
||||
The fastest starting point is the Portent template vault:
|
||||
|
||||
- [refactoringhq/portent-vault-template](https://github.com/refactoringhq/portent-vault-template)
|
||||
|
||||
Use it as-is, rename pieces to match your language, or treat it as a reference model for your own Tolaria setup.
|
||||
|
||||
## Learn More
|
||||
|
||||
Visit [portent.md](https://portent.md) for the full spec, examples, and implementation notes.
|
||||
@@ -8,6 +8,7 @@ pub enum AiAgentId {
|
||||
Opencode,
|
||||
Pi,
|
||||
Gemini,
|
||||
Kiro,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
@@ -31,6 +32,7 @@ pub struct AiAgentsStatus {
|
||||
pub opencode: AiAgentAvailability,
|
||||
pub pi: AiAgentAvailability,
|
||||
pub gemini: AiAgentAvailability,
|
||||
pub kiro: AiAgentAvailability,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -86,6 +88,7 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
opencode: crate::opencode_cli::check_cli(),
|
||||
pi: crate::pi_cli::check_cli(),
|
||||
gemini: crate::gemini_cli::check_cli(),
|
||||
kiro: crate::kiro_cli::check_cli(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,9 +152,28 @@ where
|
||||
};
|
||||
crate::gemini_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
AiAgentId::Kiro => run_kiro_agent_stream(request, permission_mode, emit),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_kiro_agent_stream<F>(
|
||||
request: AiAgentStreamRequest,
|
||||
permission_mode: AiAgentPermissionMode,
|
||||
emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let mapped = crate::cli_agent_runtime::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
vault_paths: request.vault_paths,
|
||||
permission_mode,
|
||||
};
|
||||
crate::kiro_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
|
||||
fn availability_from_claude() -> AiAgentAvailability {
|
||||
let status = crate::claude_cli::check_cli();
|
||||
AiAgentAvailability {
|
||||
|
||||
97
src-tauri/src/app_icon.rs
Normal file
97
src-tauri/src/app_icon.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use tauri::Manager;
|
||||
|
||||
const LIGHT_ICON_BYTES: &[u8] = include_bytes!("../icons/512x512.png");
|
||||
const DARK_ICON_BYTES: &[u8] = include_bytes!("../icons/512x512-dark.png");
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum AppIconMode {
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
impl AppIconMode {
|
||||
fn parse(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"light" => Ok(Self::Light),
|
||||
"dark" => Ok(Self::Dark),
|
||||
_ => Err(format!("Unsupported app icon theme mode: {value}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn png_bytes(self) -> &'static [u8] {
|
||||
match self {
|
||||
Self::Light => LIGHT_ICON_BYTES,
|
||||
Self::Dark => DARK_ICON_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_app_icon_for_theme(
|
||||
app_handle: &tauri::AppHandle,
|
||||
theme_mode: &str,
|
||||
) -> Result<(), String> {
|
||||
let icon_bytes = AppIconMode::parse(theme_mode)?.png_bytes();
|
||||
let image = tauri::image::Image::from_bytes(icon_bytes)
|
||||
.map_err(|err| format!("Failed to decode app icon: {err}"))?;
|
||||
|
||||
for window in app_handle.webview_windows().into_values() {
|
||||
window
|
||||
.set_icon(image.clone())
|
||||
.map_err(|err| format!("Failed to update window icon: {err}"))?;
|
||||
}
|
||||
|
||||
apply_platform_app_icon(app_handle, icon_bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn apply_platform_app_icon(
|
||||
app_handle: &tauri::AppHandle,
|
||||
icon_bytes: &'static [u8],
|
||||
) -> Result<(), String> {
|
||||
app_handle
|
||||
.run_on_main_thread(move || {
|
||||
if let Err(err) = set_macos_application_icon(icon_bytes) {
|
||||
log::warn!("Failed to update macOS application icon: {err}");
|
||||
}
|
||||
})
|
||||
.map_err(|err| format!("Failed to schedule macOS app icon update: {err}"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_macos_application_icon(icon_bytes: &[u8]) -> Result<(), String> {
|
||||
use objc2::AllocAnyThread;
|
||||
use objc2_app_kit::{NSApplication, NSImage};
|
||||
use objc2_foundation::{MainThreadMarker, NSData};
|
||||
|
||||
let marker = unsafe { MainThreadMarker::new_unchecked() };
|
||||
let app = NSApplication::sharedApplication(marker);
|
||||
let data = NSData::with_bytes(icon_bytes);
|
||||
let app_icon = NSImage::initWithData(NSImage::alloc(), &data)
|
||||
.ok_or_else(|| "Failed to create macOS app icon image".to_string())?;
|
||||
unsafe { app.setApplicationIconImage(Some(&app_icon)) };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn apply_platform_app_icon(
|
||||
_app_handle: &tauri::AppHandle,
|
||||
_icon_bytes: &'static [u8],
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AppIconMode;
|
||||
|
||||
#[test]
|
||||
fn parses_supported_icon_modes() {
|
||||
assert_eq!(AppIconMode::parse("light"), Ok(AppIconMode::Light));
|
||||
assert_eq!(AppIconMode::parse("dark"), Ok(AppIconMode::Dark));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_icon_modes() {
|
||||
assert!(AppIconMode::parse("system").is_err());
|
||||
}
|
||||
}
|
||||
@@ -118,20 +118,33 @@ fn claude_path_from_shell(shell: &Path) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
if output.status.success() {
|
||||
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
first_existing_path_for_platform(stdout, cfg!(windows))
|
||||
}
|
||||
|
||||
fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
|
||||
let mut paths = stdout.lines().filter_map(existing_path);
|
||||
if windows {
|
||||
return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path));
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(&output.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)
|
||||
})
|
||||
paths.next()
|
||||
}
|
||||
|
||||
fn existing_path(line: &str) -> Option<PathBuf> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
}
|
||||
|
||||
fn claude_binary_candidates() -> Vec<PathBuf> {
|
||||
@@ -710,6 +723,22 @@ mod tests {
|
||||
assert!(candidates.contains(&claude), "missing {}", claude.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let shell_script = dir.path().join("claude");
|
||||
let cmd_shim = dir.path().join("claude.cmd");
|
||||
std::fs::write(&shell_script, "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(&cmd_shim, "@ECHO off\n").unwrap();
|
||||
|
||||
let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display());
|
||||
|
||||
assert_eq!(
|
||||
first_existing_path_for_platform(&stdout, true),
|
||||
Some(cmd_shim)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_claude_flag_errors_are_detected() {
|
||||
assert!(is_unsupported_claude_flag_error(ClaudeStderr(
|
||||
|
||||
@@ -455,6 +455,97 @@ where
|
||||
Ok(run.session_id)
|
||||
}
|
||||
|
||||
/// Shared binary discovery: look up a CLI command by name using PATH, login shell, then candidates.
|
||||
pub(crate) fn find_cli_binary(
|
||||
name: &str,
|
||||
candidates: Vec<PathBuf>,
|
||||
label: &str,
|
||||
install_hint: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_binary_on_path(name) {
|
||||
return Ok(binary);
|
||||
}
|
||||
if let Some(binary) = find_binary_in_user_shell(name) {
|
||||
return Ok(binary);
|
||||
}
|
||||
if let Some(binary) = find_executable_binary_candidate(candidates, label)? {
|
||||
return Ok(binary);
|
||||
}
|
||||
Err(format!("{label} not found. Install it: {install_hint}"))
|
||||
}
|
||||
|
||||
fn find_binary_on_path(name: &str) -> Option<PathBuf> {
|
||||
let cmd = if cfg!(windows) { "where" } else { "which" };
|
||||
crate::hidden_command(cmd)
|
||||
.arg(name)
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn find_binary_in_user_shell(name: &str) -> Option<PathBuf> {
|
||||
shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, name))
|
||||
}
|
||||
|
||||
fn 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
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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)
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared check_cli pattern for CLI agents.
|
||||
pub(crate) fn check_cli_availability(
|
||||
find_binary: impl FnOnce() -> Result<PathBuf, String>,
|
||||
) -> crate::ai_agents::AiAgentAvailability {
|
||||
match find_binary() {
|
||||
Ok(binary) => crate::ai_agents::AiAgentAvailability {
|
||||
installed: true,
|
||||
version: version_for_binary(&binary),
|
||||
},
|
||||
Err(_) => crate::ai_agents::AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -4,20 +4,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
let binary = match find_codex_binary() {
|
||||
Ok(binary) => binary,
|
||||
Err(_) => {
|
||||
return AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
AiAgentAvailability {
|
||||
installed: true,
|
||||
version: crate::cli_agent_runtime::version_for_binary(&binary),
|
||||
}
|
||||
codex_availability_from_binary_result(find_codex_binary())
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
|
||||
@@ -29,15 +16,37 @@ where
|
||||
}
|
||||
|
||||
fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
find_codex_binary_on_path()
|
||||
.filter(|binary| is_usable_codex_binary(binary))
|
||||
.or_else(|| {
|
||||
find_codex_binary_in_user_shell().filter(|binary| is_usable_codex_binary(binary))
|
||||
})
|
||||
.or_else(|| find_usable_codex_binary(codex_binary_candidates()))
|
||||
.ok_or_else(|| {
|
||||
"Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into()
|
||||
})
|
||||
if let Some(binary) = find_codex_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_codex_binary_in_user_shell() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate(
|
||||
codex_binary_candidates(),
|
||||
"Codex CLI",
|
||||
)? {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
|
||||
}
|
||||
|
||||
fn codex_availability_from_binary_result(
|
||||
binary_result: Result<PathBuf, String>,
|
||||
) -> AiAgentAvailability {
|
||||
match binary_result {
|
||||
Ok(binary) => AiAgentAvailability {
|
||||
installed: true,
|
||||
version: crate::cli_agent_runtime::version_for_binary(&binary),
|
||||
},
|
||||
Err(_) => AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn find_codex_binary_on_path() -> Option<PathBuf> {
|
||||
@@ -60,7 +69,7 @@ fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, "codex"))
|
||||
.find_map(|shell| codex_path_from_shell(&shell))
|
||||
}
|
||||
|
||||
fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
@@ -75,10 +84,10 @@ fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
shells
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
fn codex_path_from_shell(shell: &Path) -> Option<PathBuf> {
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.arg("command -v codex")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
@@ -93,14 +102,25 @@ fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf>
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
first_existing_path_for_platform(stdout, cfg!(windows))
|
||||
}
|
||||
|
||||
fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
|
||||
let mut paths = stdout.lines().filter_map(existing_path);
|
||||
if windows {
|
||||
return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path));
|
||||
}
|
||||
|
||||
paths.next()
|
||||
}
|
||||
|
||||
fn existing_path(line: &str) -> Option<PathBuf> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
}
|
||||
|
||||
fn codex_binary_candidates() -> Vec<PathBuf> {
|
||||
@@ -113,12 +133,16 @@ fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".local/bin/codex.exe"),
|
||||
home.join(".local/bin/codex.cmd"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".codex/bin/codex.exe"),
|
||||
home.join(".codex/bin/codex.cmd"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".local/share/mise/shims/codex.exe"),
|
||||
home.join(".local/share/mise/shims/codex.cmd"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".asdf/shims/codex.exe"),
|
||||
home.join(".asdf/shims/codex.cmd"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".npm-global/bin/codex.cmd"),
|
||||
home.join(".npm-global/bin/codex.exe"),
|
||||
@@ -127,22 +151,24 @@ fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
home.join(".npm/bin/codex.exe"),
|
||||
home.join(".bun/bin/codex"),
|
||||
home.join(".bun/bin/codex.exe"),
|
||||
home.join(".bun/bin/codex.cmd"),
|
||||
home.join(".linuxbrew/bin/codex"),
|
||||
home.join("AppData/Roaming/npm/codex.cmd"),
|
||||
home.join("AppData/Roaming/npm/codex.exe"),
|
||||
home.join("AppData/Local/pnpm/codex.cmd"),
|
||||
home.join("AppData/Local/pnpm/codex.exe"),
|
||||
home.join("scoop/shims/codex.cmd"),
|
||||
home.join("scoop/shims/codex.exe"),
|
||||
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
];
|
||||
candidates.extend(nvm_node_binary_candidates_for_home(home, "codex"));
|
||||
candidates.extend(nvm_codex_binary_candidates_for_home(home));
|
||||
candidates
|
||||
}
|
||||
|
||||
fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<PathBuf> {
|
||||
fn nvm_codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -151,22 +177,12 @@ fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<Pa
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.map(|path| path.join("bin").join(binary_name))
|
||||
.map(|path| path.join("bin").join("codex"))
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort();
|
||||
candidates
|
||||
}
|
||||
|
||||
fn find_usable_codex_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|binary| is_usable_codex_binary(binary))
|
||||
}
|
||||
|
||||
fn is_usable_codex_binary(binary: &Path) -> bool {
|
||||
crate::cli_agent_runtime::version_for_binary(binary).is_some()
|
||||
}
|
||||
|
||||
fn run_agent_stream_with_binary<F>(
|
||||
binary: &Path,
|
||||
request: AgentStreamRequest,
|
||||
@@ -191,7 +207,12 @@ where
|
||||
emit,
|
||||
codex_session_id,
|
||||
dispatch_codex_event,
|
||||
format_codex_error,
|
||||
|stderr_output, status| {
|
||||
format_codex_error(CodexProcessError {
|
||||
stderr_output,
|
||||
status,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -265,7 +286,7 @@ fn build_codex_args(
|
||||
"-c".into(),
|
||||
codex_config_string_list("mcp_servers.tolaria.args", &[mcp_server_path.as_str()]),
|
||||
"-c".into(),
|
||||
codex_mcp_env_config(&request.vault_path, &request.vault_paths),
|
||||
codex_mcp_env_config(request),
|
||||
];
|
||||
|
||||
if let Some(path) = last_message_path {
|
||||
@@ -289,11 +310,14 @@ fn codex_config_string_list(key: &str, values: &[&str]) -> String {
|
||||
format!("{key}=[{values}]")
|
||||
}
|
||||
|
||||
fn codex_mcp_env_config(vault_path: &str, vault_paths: &[String]) -> String {
|
||||
let vault_paths = crate::cli_agent_runtime::active_vault_paths_json(vault_path, vault_paths);
|
||||
fn codex_mcp_env_config(request: &AgentStreamRequest) -> String {
|
||||
let vault_paths = crate::cli_agent_runtime::active_vault_paths_json(
|
||||
&request.vault_path,
|
||||
&request.vault_paths,
|
||||
);
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}",VAULT_PATHS="{}",WS_UI_PORT="9711"}}"#,
|
||||
toml_escape(vault_path),
|
||||
toml_escape(&request.vault_path),
|
||||
toml_escape(&vault_paths)
|
||||
)
|
||||
}
|
||||
@@ -431,8 +455,13 @@ fn read_codex_last_message(path: &Path) -> Option<String> {
|
||||
.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
fn format_codex_error(stderr_output: String, status: String) -> String {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
struct CodexProcessError {
|
||||
stderr_output: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
fn format_codex_error(error: CodexProcessError) -> String {
|
||||
let lower = error.stderr_output.to_ascii_lowercase();
|
||||
if is_codex_auth_error(&lower) {
|
||||
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
|
||||
}
|
||||
@@ -441,10 +470,15 @@ fn format_codex_error(stderr_output: String, status: String) -> String {
|
||||
return "Codex could not write to the active vault. Vault Safe uses a read-only Codex sandbox; switch to Power User for shell-backed local writes, or 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}")
|
||||
if error.stderr_output.trim().is_empty() {
|
||||
format!("codex exited with status {}", error.status)
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
error
|
||||
.stderr_output
|
||||
.lines()
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,16 +979,22 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
|
||||
let candidates = codex_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/codex.exe"),
|
||||
home.join(".local/bin/codex.cmd"),
|
||||
home.join(".local/share/mise/shims/codex.exe"),
|
||||
home.join(".local/share/mise/shims/codex.cmd"),
|
||||
home.join(".asdf/shims/codex.exe"),
|
||||
home.join(".asdf/shims/codex.cmd"),
|
||||
home.join(".codex/bin/codex.cmd"),
|
||||
home.join(".npm-global/bin/codex.cmd"),
|
||||
home.join(".npm-global/bin/codex.exe"),
|
||||
home.join(".npm/bin/codex.cmd"),
|
||||
home.join(".npm/bin/codex.exe"),
|
||||
home.join(".bun/bin/codex.cmd"),
|
||||
home.join("AppData/Roaming/npm/codex.cmd"),
|
||||
home.join("AppData/Roaming/npm/codex.exe"),
|
||||
home.join("AppData/Local/pnpm/codex.cmd"),
|
||||
home.join("AppData/Local/pnpm/codex.exe"),
|
||||
home.join("scoop/shims/codex.cmd"),
|
||||
home.join("scoop/shims/codex.exe"),
|
||||
];
|
||||
|
||||
@@ -967,6 +1007,16 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_availability_reports_installed_even_when_version_probe_fails() {
|
||||
let binary = PathBuf::from("C:/Users/alex/AppData/Roaming/npm/codex.cmd");
|
||||
|
||||
let availability = codex_availability_from_binary_result(Ok(binary));
|
||||
|
||||
assert!(availability.installed);
|
||||
assert_eq!(availability.version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_nvm_managed_node_installs() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
@@ -979,18 +1029,6 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
|
||||
assert!(candidates.contains(&codex), "missing {}", codex.display());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn usable_codex_binary_skips_broken_shims() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let broken = executable_script(dir.path(), "broken-codex", "exit 1\n");
|
||||
let working = executable_script(dir.path(), "codex", "echo codex-cli 0.124.0-alpha.2\n");
|
||||
|
||||
let found = find_usable_codex_binary(vec![broken, working.clone()]);
|
||||
|
||||
assert_eq!(found, Some(working));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -1003,6 +1041,22 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
|
||||
assert_eq!(first_existing_path(&stdout), Some(codex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let shell_script = dir.path().join("codex");
|
||||
let cmd_shim = dir.path().join("codex.cmd");
|
||||
std::fs::write(&shell_script, "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(&cmd_shim, "@ECHO off\n").unwrap();
|
||||
|
||||
let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display());
|
||||
|
||||
assert_eq!(
|
||||
first_existing_path_for_platform(&stdout, true),
|
||||
Some(cmd_shim)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_path_from_shell_finds_codex_from_login_shell() {
|
||||
@@ -1024,7 +1078,7 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex));
|
||||
assert_eq!(codex_path_from_shell(&shell), Some(codex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1129,10 +1183,10 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
|
||||
|
||||
#[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(),
|
||||
);
|
||||
let message = format_codex_error(CodexProcessError {
|
||||
stderr_output: "The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
|
||||
status: "exit status: 1".into(),
|
||||
});
|
||||
|
||||
assert!(message.contains("active vault"));
|
||||
assert!(message.contains("writable"));
|
||||
|
||||
11
src-tauri/src/commands/app_icon.rs
Normal file
11
src-tauri/src/commands/app_icon.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn update_app_icon(app_handle: tauri::AppHandle, theme_mode: String) -> Result<(), String> {
|
||||
crate::app_icon::update_app_icon_for_theme(&app_handle, &theme_mode)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn update_app_icon(_theme_mode: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
207
src-tauri/src/commands/clipboard.rs
Normal file
207
src-tauri/src/commands/clipboard.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
#[cfg(desktop)]
|
||||
use std::io::Write;
|
||||
#[cfg(desktop)]
|
||||
use std::process::{Child, Command, Output, Stdio};
|
||||
#[cfg(desktop)]
|
||||
use std::thread;
|
||||
#[cfg(desktop)]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(desktop)]
|
||||
const NATIVE_CLIPBOARD_COMMAND_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
#[cfg(desktop)]
|
||||
const NATIVE_CLIPBOARD_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("pbcopy")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_read_command() -> Command {
|
||||
crate::hidden_command("pbpaste")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("clip.exe")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_read_command() -> Command {
|
||||
let mut command = crate::hidden_command("powershell.exe");
|
||||
command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"if command -v wl-copy >/dev/null 2>&1; then wl-copy; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input; else exit 127; fi",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_read_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn clipboard_failure_message(stderr: &[u8]) -> String {
|
||||
let message = String::from_utf8_lossy(stderr).trim().to_string();
|
||||
if message.is_empty() {
|
||||
"Native clipboard command failed".to_string()
|
||||
} else {
|
||||
format!("Native clipboard command failed: {message}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn clipboard_timeout_message(timeout: Duration) -> String {
|
||||
format!(
|
||||
"Native clipboard command timed out after {}ms",
|
||||
timeout.as_millis()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn wait_for_native_clipboard_output(mut child: Child, timeout: Duration) -> Result<Output, String> {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_status)) => {
|
||||
return child
|
||||
.wait_with_output()
|
||||
.map_err(|e| format!("Native clipboard command did not finish: {e}"));
|
||||
}
|
||||
Ok(None) if started.elapsed() >= timeout => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(clipboard_timeout_message(timeout));
|
||||
}
|
||||
Ok(None) => thread::sleep(NATIVE_CLIPBOARD_COMMAND_POLL_INTERVAL),
|
||||
Err(e) => return Err(format!("Native clipboard command did not finish: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn write_native_clipboard_with_timeout(
|
||||
mut command: Command,
|
||||
text: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<(), String> {
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open native clipboard command: {e}"))?;
|
||||
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Native clipboard command did not expose stdin".to_string())?;
|
||||
stdin
|
||||
.write_all(text.as_bytes())
|
||||
.map_err(|e| format!("Failed to write native clipboard text: {e}"))?;
|
||||
drop(stdin);
|
||||
|
||||
let output = wait_for_native_clipboard_output(child, timeout)?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(clipboard_failure_message(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn write_native_clipboard(command: Command, text: &str) -> Result<(), String> {
|
||||
write_native_clipboard_with_timeout(command, text, NATIVE_CLIPBOARD_COMMAND_TIMEOUT)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn read_native_clipboard_with_timeout(
|
||||
mut command: Command,
|
||||
timeout: Duration,
|
||||
) -> Result<String, String> {
|
||||
let child = command
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to read native clipboard text: {e}"))?;
|
||||
|
||||
let output = wait_for_native_clipboard_output(child, timeout)?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(clipboard_failure_message(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn read_native_clipboard(command: Command) -> Result<String, String> {
|
||||
read_native_clipboard_with_timeout(command, NATIVE_CLIPBOARD_COMMAND_TIMEOUT)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn copy_text_to_clipboard(text: String) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || write_native_clipboard(clipboard_command(), &text))
|
||||
.await
|
||||
.map_err(|e| format!("Native clipboard task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn read_text_from_clipboard() -> Result<String, String> {
|
||||
tokio::task::spawn_blocking(move || read_native_clipboard(clipboard_read_command()))
|
||||
.await
|
||||
.map_err(|e| format!("Native clipboard task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn read_text_from_clipboard() -> Result<String, String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(all(desktop, unix))]
|
||||
#[test]
|
||||
fn native_clipboard_write_times_out_slow_commands() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "cat >/dev/null; sleep 2"]);
|
||||
|
||||
let started = Instant::now();
|
||||
let result =
|
||||
write_native_clipboard_with_timeout(command, "copy me", Duration::from_millis(50));
|
||||
|
||||
let error = result.expect_err("slow clipboard command should time out");
|
||||
assert!(
|
||||
error.contains("timed out"),
|
||||
"unexpected clipboard timeout error: {error}"
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(1),
|
||||
"clipboard timeout should return promptly"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
mod ai;
|
||||
mod app_icon;
|
||||
mod clipboard;
|
||||
mod delete;
|
||||
mod folders;
|
||||
mod git;
|
||||
pub mod git_clone;
|
||||
mod git_connect;
|
||||
mod memory;
|
||||
mod runtime;
|
||||
mod system;
|
||||
mod vault;
|
||||
mod version;
|
||||
@@ -12,11 +15,14 @@ mod version;
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub use ai::*;
|
||||
pub use app_icon::*;
|
||||
pub use clipboard::*;
|
||||
pub use delete::*;
|
||||
pub use folders::*;
|
||||
pub use git::*;
|
||||
pub use git_connect::*;
|
||||
pub use memory::*;
|
||||
pub use runtime::*;
|
||||
pub use system::*;
|
||||
pub use vault::*;
|
||||
pub use version::*;
|
||||
|
||||
29
src-tauri/src/commands/runtime.rs
Normal file
29
src-tauri/src/commands/runtime.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
fn should_use_external_media_preview_for_appimage(is_linux_appimage: bool) -> bool {
|
||||
is_linux_appimage
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn linux_appimage_running() -> bool {
|
||||
crate::linux_appimage::is_running()
|
||||
}
|
||||
|
||||
#[cfg(not(all(desktop, target_os = "linux")))]
|
||||
fn linux_appimage_running() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn should_use_external_media_preview() -> bool {
|
||||
should_use_external_media_preview_for_appimage(linux_appimage_running())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_use_external_media_preview_for_appimage;
|
||||
|
||||
#[test]
|
||||
fn external_media_preview_is_limited_to_linux_appimage() {
|
||||
assert!(should_use_external_media_preview_for_appimage(true));
|
||||
assert!(!should_use_external_media_preview_for_appimage(false));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
#[cfg(desktop)]
|
||||
use std::io::Write;
|
||||
#[cfg(desktop)]
|
||||
use std::process::{Command, Stdio};
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use crate::menu;
|
||||
@@ -142,113 +140,6 @@ pub async fn get_mcp_config_snippet(vault_path: String) -> Result<String, String
|
||||
.map_err(|e| format!("MCP config task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("pbcopy")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_read_command() -> Command {
|
||||
crate::hidden_command("pbpaste")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("clip.exe")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_read_command() -> Command {
|
||||
let mut command = crate::hidden_command("powershell.exe");
|
||||
command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"if command -v wl-copy >/dev/null 2>&1; then wl-copy; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input; else exit 127; fi",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_read_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn clipboard_failure_message(stderr: &[u8]) -> String {
|
||||
let message = String::from_utf8_lossy(stderr).trim().to_string();
|
||||
if message.is_empty() {
|
||||
"Native clipboard command failed".to_string()
|
||||
} else {
|
||||
format!("Native clipboard command failed: {message}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn write_native_clipboard(mut command: Command, text: &str) -> Result<(), String> {
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open native clipboard command: {e}"))?;
|
||||
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Native clipboard command did not expose stdin".to_string())?;
|
||||
stdin
|
||||
.write_all(text.as_bytes())
|
||||
.map_err(|e| format!("Failed to write native clipboard text: {e}"))?;
|
||||
drop(stdin);
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| format!("Native clipboard command did not finish: {e}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(clipboard_failure_message(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn read_native_clipboard(mut command: Command) -> Result<String, String> {
|
||||
let output = command
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to read native clipboard text: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(clipboard_failure_message(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn copy_text_to_clipboard(text: String) -> Result<(), String> {
|
||||
write_native_clipboard(clipboard_command(), &text)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn read_text_from_clipboard() -> Result<String, String> {
|
||||
read_native_clipboard(clipboard_read_command())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(
|
||||
@@ -299,18 +190,6 @@ pub async fn get_mcp_config_snippet(_vault_path: String) -> Result<String, Strin
|
||||
Err("MCP is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn read_text_from_clipboard() -> Result<String, String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(
|
||||
@@ -384,9 +263,33 @@ pub fn trigger_menu_command(_app_handle: tauri::AppHandle, _id: String) -> Resul
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn update_current_window_min_size(
|
||||
window: Window,
|
||||
fn should_apply_window_min_size_constraints(
|
||||
is_windows: bool,
|
||||
is_fullscreen: bool,
|
||||
is_maximized: bool,
|
||||
) -> bool {
|
||||
!(is_windows && (is_fullscreen || is_maximized))
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn should_skip_window_min_size_update(window: &Window) -> Result<bool, String> {
|
||||
if !cfg!(target_os = "windows") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let is_fullscreen = window.is_fullscreen().map_err(|e| e.to_string())?;
|
||||
let is_maximized = window.is_maximized().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(!should_apply_window_min_size_constraints(
|
||||
true,
|
||||
is_fullscreen,
|
||||
is_maximized,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn apply_window_min_size_update(
|
||||
window: &Window,
|
||||
min_width: f64,
|
||||
min_height: f64,
|
||||
grow_to_fit: bool,
|
||||
@@ -416,6 +319,21 @@ pub fn update_current_window_min_size(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn update_current_window_min_size(
|
||||
window: Window,
|
||||
min_width: f64,
|
||||
min_height: f64,
|
||||
grow_to_fit: bool,
|
||||
) -> Result<(), String> {
|
||||
if should_skip_window_min_size_update(&window)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
apply_window_min_size_update(&window, min_width, min_height, grow_to_fit)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn perform_current_window_titlebar_double_click(window: Window) -> Result<(), String> {
|
||||
@@ -623,6 +541,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_min_size_updates_for_windows_fullscreen_or_maximized_windows() {
|
||||
for (is_fullscreen, is_maximized) in [(true, false), (false, true), (true, true)] {
|
||||
assert!(!should_apply_window_min_size_constraints(
|
||||
true,
|
||||
is_fullscreen,
|
||||
is_maximized
|
||||
));
|
||||
}
|
||||
|
||||
assert!(should_apply_window_min_size_constraints(true, false, false));
|
||||
assert!(should_apply_window_min_size_constraints(false, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagates_title_bar_action_errors() {
|
||||
for (state, maximize, unmaximize, expected) in [
|
||||
|
||||
@@ -129,17 +129,17 @@ fn load_configured_active_vault_root() -> Result<Option<VaultRootPaths>, String>
|
||||
|
||||
fn load_registered_vault_roots() -> Result<Vec<VaultRootPaths>, String> {
|
||||
let list = vault_list::load_vault_list()?;
|
||||
let mut roots = list
|
||||
.vaults
|
||||
Ok(registered_vault_roots(&list))
|
||||
}
|
||||
|
||||
fn registered_vault_roots(list: &vault_list::VaultList) -> Vec<VaultRootPaths> {
|
||||
list.vaults
|
||||
.iter()
|
||||
.map(|entry| build_vault_root_paths(&entry.path))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if let Some(active_vault) = list.active_vault.as_deref() {
|
||||
if !active_vault.trim().is_empty() {
|
||||
roots.push(build_vault_root_paths(active_vault)?);
|
||||
}
|
||||
}
|
||||
Ok(roots)
|
||||
.map(|entry| entry.path.as_str())
|
||||
.chain(list.active_vault.as_deref())
|
||||
.filter(|path| !path.trim().is_empty())
|
||||
.filter_map(|path| build_vault_root_paths(path).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_registered_vault_root(requested: &VaultRootPaths) -> Result<bool, String> {
|
||||
@@ -378,3 +378,37 @@ pub(crate) fn with_view_file<T>(
|
||||
action(&requested_root, filename)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vault_list::{VaultEntry, VaultList};
|
||||
|
||||
#[test]
|
||||
fn registered_vault_roots_skip_unavailable_vaults() {
|
||||
let available = tempfile::TempDir::new().unwrap();
|
||||
let missing = available.path().join("missing-vault");
|
||||
let list = VaultList {
|
||||
vaults: vec![
|
||||
VaultEntry {
|
||||
label: "Missing".to_string(),
|
||||
path: missing.to_string_lossy().to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
VaultEntry {
|
||||
label: "Available".to_string(),
|
||||
path: available.path().to_string_lossy().to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
active_vault: None,
|
||||
default_workspace_path: None,
|
||||
hidden_defaults: vec![],
|
||||
};
|
||||
|
||||
let roots = registered_vault_roots(&list);
|
||||
|
||||
assert_eq!(roots.len(), 1);
|
||||
assert_eq!(roots[0].canonical, available.path().canonicalize().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
320
src-tauri/src/kiro_cli.rs
Normal file
320
src-tauri/src/kiro_cli.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use regex::Regex;
|
||||
use std::io::{BufRead, Read, Write};
|
||||
use std::path::Path;
|
||||
use std::process::{ChildStderr, ChildStdin, ChildStdout, Stdio};
|
||||
|
||||
struct KiroMcpConfig<'a> {
|
||||
vault_path: &'a str,
|
||||
vault_paths: &'a [String],
|
||||
mcp_server_path: &'a str,
|
||||
}
|
||||
|
||||
struct KiroError<'a> {
|
||||
stderr_output: &'a str,
|
||||
status: String,
|
||||
}
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
crate::kiro_discovery::check_cli()
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = crate::kiro_discovery::find_binary()?;
|
||||
ensure_mcp_config(&request)?;
|
||||
let prompt =
|
||||
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref());
|
||||
|
||||
let mut child = spawn_kiro_process(&binary, Path::new(&request.vault_path))?;
|
||||
let prompt_handle = write_prompt_async(
|
||||
child.stdin.take().ok_or("No stdin handle")?,
|
||||
prompt.into_bytes(),
|
||||
);
|
||||
let stderr_handle = read_stderr_async(child.stderr.take().ok_or("No stderr handle")?);
|
||||
|
||||
let session_id = generate_session_id();
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: session_id.clone(),
|
||||
});
|
||||
|
||||
stream_stdout(child.stdout.take().ok_or("No stdout handle")?, &mut emit);
|
||||
|
||||
let mut stderr_output = stderr_handle.join().unwrap_or_default();
|
||||
if let Some(error) = prompt_write_error(prompt_handle) {
|
||||
append_stderr_line(&mut stderr_output, error);
|
||||
}
|
||||
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
|
||||
if !status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format_kiro_error(KiroError {
|
||||
stderr_output: &stderr_output,
|
||||
status: status.to_string(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
fn spawn_kiro_process(binary: &Path, vault_path: &Path) -> Result<std::process::Child, String> {
|
||||
let mut command = crate::hidden_command(binary);
|
||||
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
|
||||
command
|
||||
.arg("chat")
|
||||
.arg("--no-interactive")
|
||||
.arg("--trust-all-tools")
|
||||
.current_dir(vault_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
command
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn kiro-cli: {e}"))
|
||||
}
|
||||
|
||||
fn write_prompt_async(
|
||||
mut stdin: ChildStdin,
|
||||
prompt: Vec<u8>,
|
||||
) -> std::thread::JoinHandle<Result<(), String>> {
|
||||
std::thread::spawn(move || {
|
||||
stdin
|
||||
.write_all(&prompt)
|
||||
.map_err(|e| format!("Failed to write kiro-cli stdin: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
format!("kiro-{}-{}", std::process::id(), ts)
|
||||
}
|
||||
|
||||
fn stream_stdout<F>(stdout: ChildStdout, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
Ok(l) if !l.is_empty() => {
|
||||
emit(AiAgentStreamEvent::TextDelta {
|
||||
text: format!("{}\n", strip_ansi_codes(&l)),
|
||||
});
|
||||
}
|
||||
Ok(_) => {
|
||||
emit(AiAgentStreamEvent::TextDelta {
|
||||
text: "\n".to_string(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format!("Read error: {e}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_stderr_async(mut stderr: ChildStderr) -> std::thread::JoinHandle<String> {
|
||||
std::thread::spawn(move || {
|
||||
let mut output = String::new();
|
||||
let _ = stderr.read_to_string(&mut output);
|
||||
output
|
||||
})
|
||||
}
|
||||
|
||||
fn prompt_write_error(handle: std::thread::JoinHandle<Result<(), String>>) -> Option<String> {
|
||||
match handle.join() {
|
||||
Ok(Ok(())) => None,
|
||||
Ok(Err(error)) => Some(error),
|
||||
Err(_) => Some("Failed to write kiro-cli stdin: writer thread panicked".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_stderr_line(stderr_output: &mut String, line: impl AsRef<str>) {
|
||||
if !stderr_output.is_empty() {
|
||||
stderr_output.push('\n');
|
||||
}
|
||||
stderr_output.push_str(line.as_ref());
|
||||
}
|
||||
|
||||
fn ensure_mcp_config(request: &AgentStreamRequest) -> Result<(), String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
write_mcp_json(KiroMcpConfig {
|
||||
vault_path: &request.vault_path,
|
||||
vault_paths: &request.vault_paths,
|
||||
mcp_server_path: &mcp_server_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_mcp_json(config: KiroMcpConfig<'_>) -> Result<(), String> {
|
||||
let config_dir = Path::new(config.vault_path).join(".kiro").join("settings");
|
||||
std::fs::create_dir_all(&config_dir)
|
||||
.map_err(|e| format!("Failed to create .kiro/settings: {e}"))?;
|
||||
|
||||
let config_path = config_dir.join("mcp.json");
|
||||
|
||||
let mut json_config: serde_json::Value = std::fs::read_to_string(&config_path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
let servers = json_config
|
||||
.as_object_mut()
|
||||
.ok_or("Invalid mcp.json: not an object")?
|
||||
.entry("mcpServers")
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
|
||||
let active_vault_paths =
|
||||
crate::cli_agent_runtime::active_vault_paths_json(config.vault_path, config.vault_paths);
|
||||
servers["tolaria"] = serde_json::json!({
|
||||
"command": "node",
|
||||
"args": [config.mcp_server_path],
|
||||
"env": {
|
||||
"VAULT_PATH": config.vault_path,
|
||||
"VAULT_PATHS": active_vault_paths,
|
||||
"WS_UI_PORT": "9711"
|
||||
},
|
||||
"disabled": false
|
||||
});
|
||||
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
serde_json::to_string_pretty(&json_config)
|
||||
.map_err(|e| format!("JSON serialize error: {e}"))?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to write mcp.json: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn strip_ansi_codes(input: &str) -> String {
|
||||
static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||
let re = RE.get_or_init(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
|
||||
re.replace_all(input, "").to_string()
|
||||
}
|
||||
|
||||
fn format_kiro_error(error: KiroError<'_>) -> String {
|
||||
if is_auth_error(error.stderr_output) {
|
||||
return "Kiro CLI is not authenticated. Run `kiro-cli login` in your terminal to sign in."
|
||||
.into();
|
||||
}
|
||||
if error.stderr_output.trim().is_empty() {
|
||||
format!("kiro-cli exited with status {}", error.status)
|
||||
} else {
|
||||
error
|
||||
.stderr_output
|
||||
.lines()
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_auth_error(stderr_output: &str) -> bool {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
["auth", "login", "token"]
|
||||
.iter()
|
||||
.any(|needle| lower.contains(needle))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strip_ansi_codes_removes_terminal_colors() {
|
||||
assert_eq!(
|
||||
strip_ansi_codes("\x1b[38;5;141m> \x1b[0mHello! \x1b[2K"),
|
||||
"> Hello! "
|
||||
);
|
||||
assert_eq!(strip_ansi_codes("plain text"), "plain text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_kiro_error_detects_auth_errors() {
|
||||
let result = format_kiro_error(KiroError {
|
||||
stderr_output: "Error: auth token expired",
|
||||
status: "1".into(),
|
||||
});
|
||||
assert!(result.contains("kiro-cli login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_kiro_error_returns_status_for_empty_stderr() {
|
||||
let result = format_kiro_error(KiroError {
|
||||
stderr_output: "",
|
||||
status: "1".into(),
|
||||
});
|
||||
assert!(result.contains("status 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_mcp_json_creates_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
write_mcp_json(KiroMcpConfig {
|
||||
vault_path,
|
||||
vault_paths: &["/other/vault".into(), vault_path.into()],
|
||||
mcp_server_path: "/opt/mcp/index.js",
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let config_path = dir.path().join(".kiro/settings/mcp.json");
|
||||
let content: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
|
||||
assert_eq!(content["mcpServers"]["tolaria"]["command"], "node");
|
||||
assert_eq!(
|
||||
content["mcpServers"]["tolaria"]["args"][0],
|
||||
"/opt/mcp/index.js"
|
||||
);
|
||||
assert_eq!(
|
||||
content["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
|
||||
vault_path
|
||||
);
|
||||
assert_eq!(
|
||||
content["mcpServers"]["tolaria"]["env"]["VAULT_PATHS"],
|
||||
serde_json::json!(serde_json::to_string(&vec![vault_path, "/other/vault"]).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
content["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"],
|
||||
"9711"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_mcp_json_merges_preserving_existing_servers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
let config_dir = dir.path().join(".kiro/settings");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("mcp.json"),
|
||||
r#"{"mcpServers":{"other":{"command":"python","args":["server.py"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
write_mcp_json(KiroMcpConfig {
|
||||
vault_path,
|
||||
vault_paths: &[],
|
||||
mcp_server_path: "/new/index.js",
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let content: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(dir.path().join(".kiro/settings/mcp.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(content["mcpServers"]["tolaria"]["args"][0], "/new/index.js");
|
||||
assert_eq!(content["mcpServers"]["other"]["command"], "python");
|
||||
}
|
||||
}
|
||||
59
src-tauri/src/kiro_discovery.rs
Normal file
59
src-tauri/src/kiro_discovery.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use crate::ai_agents::AiAgentAvailability;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn check_cli() -> AiAgentAvailability {
|
||||
crate::cli_agent_runtime::check_cli_availability(find_binary)
|
||||
}
|
||||
|
||||
pub(crate) fn find_binary() -> Result<PathBuf, String> {
|
||||
crate::cli_agent_runtime::find_cli_binary(
|
||||
"kiro-cli",
|
||||
kiro_binary_candidates(),
|
||||
"Kiro CLI",
|
||||
"https://kiro.dev/docs/cli",
|
||||
)
|
||||
}
|
||||
|
||||
fn kiro_binary_candidates() -> Vec<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.map(|home| kiro_binary_candidates_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn kiro_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".local/bin/kiro-cli"),
|
||||
home.join(".kiro/bin/kiro-cli"),
|
||||
home.join(".local/share/mise/shims/kiro-cli"),
|
||||
home.join(".asdf/shims/kiro-cli"),
|
||||
home.join(".npm-global/bin/kiro-cli"),
|
||||
home.join(".npm/bin/kiro-cli"),
|
||||
home.join(".bun/bin/kiro-cli"),
|
||||
PathBuf::from("/usr/local/bin/kiro-cli"),
|
||||
PathBuf::from("/opt/homebrew/bin/kiro-cli"),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_supported_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = kiro_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/kiro-cli"),
|
||||
home.join(".kiro/bin/kiro-cli"),
|
||||
home.join(".npm-global/bin/kiro-cli"),
|
||||
PathBuf::from("/opt/homebrew/bin/kiro-cli"),
|
||||
];
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod ai_agents;
|
||||
pub mod ai_models;
|
||||
mod app_icon;
|
||||
pub mod app_updater;
|
||||
pub mod claude_cli;
|
||||
mod claude_invocation;
|
||||
@@ -11,6 +12,8 @@ pub mod gemini_cli;
|
||||
mod gemini_config;
|
||||
mod gemini_discovery;
|
||||
pub mod git;
|
||||
pub mod kiro_cli;
|
||||
mod kiro_discovery;
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
mod linux_appimage;
|
||||
pub mod mcp;
|
||||
@@ -250,6 +253,14 @@ fn sync_ws_bridge_for_selected_vault(app_handle: &tauri::AppHandle) {
|
||||
fn spawn_initial_ws_bridge_sync(app: &tauri::App) {
|
||||
let app_handle = app.handle().clone();
|
||||
spawn_background_task("tolaria-ws-bridge-startup", move || {
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
if linux_appimage::is_running() {
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
if let Err(e) = mcp::extract_mcp_server_to_stable_dir(&app_version) {
|
||||
log::warn!("Failed to extract MCP server to stable path: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
sync_ws_bridge_for_selected_vault(&app_handle);
|
||||
});
|
||||
}
|
||||
@@ -274,9 +285,10 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
app.handle().plugin(tauri_plugin_process::init())?;
|
||||
app.handle().plugin(tauri_plugin_opener::init())?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
menu::setup_menu(app)?;
|
||||
setup_linux_window_chrome(app)?;
|
||||
if should_use_native_desktop_menu(std::env::consts::OS) {
|
||||
menu::setup_menu(app)?;
|
||||
}
|
||||
setup_custom_window_chrome(app)?;
|
||||
window_state::restore_main_window_state(app);
|
||||
show_debug_main_window(app);
|
||||
Ok(())
|
||||
@@ -297,8 +309,12 @@ fn show_debug_main_window(app: &mut tauri::App) {
|
||||
#[cfg(not(debug_assertions))]
|
||||
fn show_debug_main_window(_app: &mut tauri::App) {}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn setup_linux_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn should_use_native_desktop_menu(target_os: &str) -> bool {
|
||||
target_os == "macos"
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, any(target_os = "linux", target_os = "windows")))]
|
||||
fn setup_custom_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use tauri::Manager;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
@@ -307,8 +323,8 @@ fn setup_linux_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::er
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(all(desktop, target_os = "linux")))]
|
||||
fn setup_linux_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[cfg(not(all(desktop, any(target_os = "linux", target_os = "windows"))))]
|
||||
fn setup_custom_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -486,6 +502,7 @@ macro_rules! app_invoke_handler {
|
||||
commands::get_settings,
|
||||
commands::check_for_app_update,
|
||||
commands::update_menu_state,
|
||||
commands::update_app_icon,
|
||||
commands::trigger_menu_command,
|
||||
commands::update_current_window_min_size,
|
||||
commands::perform_current_window_titlebar_double_click,
|
||||
@@ -509,6 +526,7 @@ macro_rules! app_invoke_handler {
|
||||
commands::get_process_memory_snapshot,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry,
|
||||
commands::should_use_external_media_preview,
|
||||
commands::list_views,
|
||||
commands::save_view_cmd,
|
||||
commands::delete_view_cmd,
|
||||
@@ -561,6 +579,7 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_use_native_desktop_menu;
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
|
||||
|
||||
@@ -715,4 +734,11 @@ mod tests {
|
||||
missing_asset_scope_roots(&allowed_roots, std::slice::from_ref(&vault_a)).is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_desktop_menu_is_macos_only() {
|
||||
assert!(should_use_native_desktop_menu("macos"));
|
||||
assert!(!should_use_native_desktop_menu("windows"));
|
||||
assert!(!should_use_native_desktop_menu("linux"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ struct StartupEnvOverride {
|
||||
value: &'static str,
|
||||
}
|
||||
|
||||
const LINUX_APPIMAGE_WEBKIT_OVERRIDES: [StartupEnvOverride; 2] = [
|
||||
const LINUX_WEBKIT_RENDERING_OVERRIDES: [StartupEnvOverride; 2] = [
|
||||
StartupEnvOverride {
|
||||
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
|
||||
value: "1",
|
||||
@@ -25,6 +25,10 @@ const FCITX_ENV_HINT_KEYS: [&str; 4] = [
|
||||
"QT_IM_MODULE",
|
||||
"SDL_IM_MODULE",
|
||||
];
|
||||
const FCITX_GTK3_IM_MODULE_RELATIVE_PATH: &str =
|
||||
"usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so";
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
const TOLARIA_FCITX_IMMODULES_CACHE_FILE: &str = "tolaria-appimage-fcitx5-immodules.cache";
|
||||
const COLRV1_EMOJI_FONT_FILE: &str = "Noto-COLRv1.ttf";
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
const TOLARIA_COLRV1_FONTCONFIG_FILE: &str = "tolaria-appimage-no-colrv1-emoji.conf";
|
||||
@@ -54,6 +58,18 @@ where
|
||||
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()))
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
pub(crate) fn is_running() -> bool {
|
||||
is_linux_appimage_launch(|key| std::env::var(key).ok())
|
||||
}
|
||||
|
||||
fn should_disable_unstable_webkit_rendering<F>(get_var: &mut F) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
is_linux_appimage_launch(&mut *get_var) || is_wayland_session(&mut *get_var)
|
||||
}
|
||||
|
||||
fn has_non_empty_env<F>(get_var: &mut F, key: &str) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
@@ -101,6 +117,68 @@ where
|
||||
.any(|key| get_var(key).is_some_and(|value| env_mentions_fcitx(&value)))
|
||||
}
|
||||
|
||||
fn non_empty_env<F>(get_var: &mut F, key: &str) -> Option<String>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
get_var(key).filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct FcitxGtkModuleFileOverride {
|
||||
cache_path: std::path::PathBuf,
|
||||
cache_contents: String,
|
||||
}
|
||||
|
||||
fn fcitx_immodules_cache_contents(module_path: &std::path::Path) -> String {
|
||||
format!(
|
||||
r#"# Tolaria AppImage GTK input method modules
|
||||
"{}"
|
||||
"fcitx" "Fcitx 5" "fcitx" "" "ja:ko:zh:*"
|
||||
"#,
|
||||
module_path.display()
|
||||
)
|
||||
}
|
||||
|
||||
fn should_use_bundled_fcitx_gtk_module<F>(get_var: &mut F) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
if !is_linux_appimage_launch(&mut *get_var) {
|
||||
return false;
|
||||
}
|
||||
if has_non_empty_env(get_var, "GTK_IM_MODULE_FILE") {
|
||||
return false;
|
||||
}
|
||||
if let Some(gtk_im_module) = non_empty_env(get_var, "GTK_IM_MODULE") {
|
||||
return env_mentions_fcitx(>k_im_module);
|
||||
}
|
||||
|
||||
has_fcitx_env_hint(get_var)
|
||||
}
|
||||
|
||||
fn fcitx_gtk_im_module_file_override_with<F, E>(
|
||||
get_var: &mut F,
|
||||
mut module_exists: E,
|
||||
cache_path: std::path::PathBuf,
|
||||
) -> Option<FcitxGtkModuleFileOverride>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
E: FnMut(&std::path::Path) -> bool,
|
||||
{
|
||||
if !should_use_bundled_fcitx_gtk_module(get_var) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let appdir = non_empty_env(get_var, "APPDIR")?;
|
||||
let module_path = std::path::Path::new(appdir.trim()).join(FCITX_GTK3_IM_MODULE_RELATIVE_PATH);
|
||||
|
||||
module_exists(&module_path).then(|| FcitxGtkModuleFileOverride {
|
||||
cache_path,
|
||||
cache_contents: fcitx_immodules_cache_contents(&module_path),
|
||||
})
|
||||
}
|
||||
|
||||
fn fcitx_gtk_im_module_override_with<F>(get_var: &mut F) -> Option<StartupEnvOverride>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
@@ -108,9 +186,6 @@ where
|
||||
if !is_linux_appimage_launch(&mut *get_var) {
|
||||
return None;
|
||||
}
|
||||
if !is_wayland_session(&mut *get_var) {
|
||||
return None;
|
||||
}
|
||||
if has_env(get_var, "GTK_IM_MODULE") {
|
||||
return None;
|
||||
}
|
||||
@@ -225,11 +300,11 @@ fn startup_env_overrides_with<F>(mut get_var: F) -> Vec<StartupEnvOverride>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
if !is_linux_appimage_launch(&mut get_var) {
|
||||
if !should_disable_unstable_webkit_rendering(&mut get_var) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut overrides: Vec<_> = LINUX_APPIMAGE_WEBKIT_OVERRIDES
|
||||
let mut overrides: Vec<_> = LINUX_WEBKIT_RENDERING_OVERRIDES
|
||||
.into_iter()
|
||||
.filter(|env_override| !has_non_empty_env(&mut get_var, env_override.key))
|
||||
.collect();
|
||||
@@ -245,12 +320,43 @@ where
|
||||
pub(crate) fn apply_startup_env_overrides() {
|
||||
apply_wayland_client_preload();
|
||||
apply_colrv1_emoji_font_guard();
|
||||
apply_fcitx_gtk_im_module_file();
|
||||
|
||||
for env_override in startup_env_overrides_with(|key| std::env::var(key).ok()) {
|
||||
std::env::set_var(env_override.key, env_override.value);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn apply_fcitx_gtk_im_module_file() {
|
||||
let Some(cache_path) = fcitx_immodules_cache_file_path() else {
|
||||
eprintln!("Tolaria AppImage fcitx GTK module skipped: failed to resolve cache directory");
|
||||
return;
|
||||
};
|
||||
let Some(env_override) = fcitx_gtk_im_module_file_override_with(
|
||||
&mut |key| std::env::var(key).ok(),
|
||||
std::path::Path::is_file,
|
||||
cache_path,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let Some(parent) = env_override.cache_path.parent() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(error) = std::fs::create_dir_all(parent) {
|
||||
eprintln!("Tolaria AppImage fcitx GTK module skipped: failed to prepare cache ({error})");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = std::fs::write(&env_override.cache_path, env_override.cache_contents) {
|
||||
eprintln!("Tolaria AppImage fcitx GTK module skipped: failed to write cache ({error})");
|
||||
return;
|
||||
}
|
||||
|
||||
std::env::set_var("GTK_IM_MODULE_FILE", env_override.cache_path.as_os_str());
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn apply_colrv1_emoji_font_guard() {
|
||||
let Some(font_path) =
|
||||
@@ -309,6 +415,18 @@ fn colrv1_fontconfig_file_path() -> Option<std::path::PathBuf> {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn fcitx_immodules_cache_file_path() -> Option<std::path::PathBuf> {
|
||||
let cache_dir =
|
||||
dirs::cache_dir().or_else(|| dirs::home_dir().map(|home| home.join(".cache")))?;
|
||||
|
||||
Some(
|
||||
cache_dir
|
||||
.join("tolaria")
|
||||
.join(TOLARIA_FCITX_IMMODULES_CACHE_FILE),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn launched_appimage_path() -> Result<std::path::PathBuf, String> {
|
||||
if let Some(appimage) = std::env::var_os("APPIMAGE").filter(|value| !value.is_empty()) {
|
||||
@@ -366,7 +484,8 @@ fn apply_wayland_client_preload() {
|
||||
mod tests {
|
||||
use super::{
|
||||
colrv1_emoji_font_path_with, colrv1_emoji_fontconfig_contents, elf_library_matches_process,
|
||||
startup_env_overrides_with, wayland_client_preload_path_with, StartupEnvOverride,
|
||||
fcitx_gtk_im_module_file_override_with, startup_env_overrides_with,
|
||||
wayland_client_preload_path_with, StartupEnvOverride, FCITX_GTK3_IM_MODULE_RELATIVE_PATH,
|
||||
};
|
||||
|
||||
fn default_webkit_overrides() -> Vec<StartupEnvOverride> {
|
||||
@@ -402,14 +521,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_env_overrides_are_empty_outside_appimage_launches() {
|
||||
fn startup_env_overrides_are_empty_outside_appimage_or_wayland_launches() {
|
||||
let overrides = startup_env_overrides_with(|_| None);
|
||||
|
||||
assert!(overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_env_overrides_disable_unstable_webkit_rendering_for_appimages() {
|
||||
fn startup_env_overrides_disable_unstable_webkit_rendering_for_appimage_launches() {
|
||||
let overrides = startup_env_overrides_with(|key| match key {
|
||||
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
|
||||
_ => None,
|
||||
@@ -418,6 +537,16 @@ mod tests {
|
||||
assert_eq!(overrides, default_webkit_overrides());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_env_overrides_disable_unstable_webkit_rendering_for_native_wayland_launches() {
|
||||
let overrides = startup_env_overrides_with(|key| match key {
|
||||
"XDG_SESSION_TYPE" => Some("wayland".to_string()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
assert_eq!(overrides, default_webkit_overrides());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_env_overrides_preserve_explicit_user_setting_per_variable() {
|
||||
let overrides = startup_env_overrides_with(|key| match key {
|
||||
@@ -442,6 +571,18 @@ mod tests {
|
||||
assert_eq!(overrides, default_webkit_and_fcitx_overrides());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_env_overrides_enable_fcitx_gtk_module_for_x11_appimage() {
|
||||
let overrides = startup_env_overrides_with(|key| match key {
|
||||
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
|
||||
"XDG_SESSION_TYPE" => Some("x11".to_string()),
|
||||
"XMODIFIERS" => Some("@im=fcitx".to_string()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
assert_eq!(overrides, default_webkit_and_fcitx_overrides());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_env_overrides_preserve_explicit_gtk_im_module() {
|
||||
let overrides =
|
||||
@@ -461,6 +602,50 @@ mod tests {
|
||||
assert_eq!(overrides, default_webkit_overrides());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fcitx_module_file_override_points_gtk_to_bundled_appimage_module() {
|
||||
let appdir = std::path::Path::new("/tmp/.mount_Tolaria");
|
||||
let module_path = appdir.join(FCITX_GTK3_IM_MODULE_RELATIVE_PATH);
|
||||
let cache_path = std::path::PathBuf::from("/tmp/tolaria/immodules.cache");
|
||||
let env_override = fcitx_gtk_im_module_file_override_with(
|
||||
&mut |key| match key {
|
||||
"APPDIR" => Some(appdir.display().to_string()),
|
||||
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
|
||||
"GTK_IM_MODULE" => Some("fcitx".to_string()),
|
||||
"XDG_SESSION_TYPE" => Some("x11".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
|path| path == module_path,
|
||||
cache_path.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(env_override.cache_path, cache_path);
|
||||
assert!(env_override
|
||||
.cache_contents
|
||||
.contains(&module_path.display().to_string()));
|
||||
assert!(env_override
|
||||
.cache_contents
|
||||
.contains("\"fcitx\" \"Fcitx 5\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fcitx_module_file_override_preserves_explicit_module_cache() {
|
||||
let env_override = fcitx_gtk_im_module_file_override_with(
|
||||
&mut |key| match key {
|
||||
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
|
||||
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
|
||||
"GTK_IM_MODULE" => Some("fcitx".to_string()),
|
||||
"GTK_IM_MODULE_FILE" => Some("/tmp/custom-immodules.cache".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
|_| true,
|
||||
std::path::PathBuf::from("/tmp/tolaria/immodules.cache"),
|
||||
);
|
||||
|
||||
assert_eq!(env_override, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colrv1_font_guard_targets_reported_appimage_emoji_font() {
|
||||
let font_path = colrv1_emoji_font_path_with(
|
||||
|
||||
@@ -2,9 +2,15 @@ use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command};
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
mod extraction;
|
||||
mod opencode;
|
||||
mod paths;
|
||||
mod subprocess;
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
pub(crate) use extraction::extract_mcp_server_to_stable_dir;
|
||||
|
||||
const MCP_SERVER_NAME: &str = "tolaria";
|
||||
const LEGACY_MCP_SERVER_NAME: &str = "laputa";
|
||||
|
||||
@@ -18,44 +24,108 @@ pub enum McpStatus {
|
||||
NotInstalled,
|
||||
}
|
||||
|
||||
/// Find the `node` binary path at runtime.
|
||||
pub(crate) fn find_node() -> Result<PathBuf, String> {
|
||||
let mut last_error = None;
|
||||
for path in node_binary_candidates() {
|
||||
match verify_node_version(&path) {
|
||||
Ok(()) => return Ok(path),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| "node not found in PATH or common install locations".into()))
|
||||
/// A resolved runtime that can execute the MCP server scripts.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct McpRuntime {
|
||||
pub(crate) kind: McpRuntimeKind,
|
||||
pub(crate) binary: PathBuf,
|
||||
}
|
||||
|
||||
fn node_binary_candidates() -> Vec<PathBuf> {
|
||||
let mut candidates = find_node_on_path();
|
||||
candidates.extend(find_node_in_user_shell());
|
||||
candidates.extend(fallback_node_paths());
|
||||
/// Which JS runtime was selected for the MCP server.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum McpRuntimeKind {
|
||||
Node,
|
||||
Bun,
|
||||
}
|
||||
|
||||
impl McpRuntimeKind {
|
||||
fn binary_name(self) -> &'static str {
|
||||
match self {
|
||||
McpRuntimeKind::Node => node_binary_name(),
|
||||
McpRuntimeKind::Bun => bun_binary_name(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find any supported MCP runtime, preferring Node over Bun.
|
||||
pub(crate) fn find_mcp_runtime() -> Result<McpRuntime, String> {
|
||||
let mut last_error = None;
|
||||
for kind in [McpRuntimeKind::Node, McpRuntimeKind::Bun] {
|
||||
if let Some(binary) = try_runtime(kind, &mut last_error) {
|
||||
return Ok(McpRuntime { kind, binary });
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
"No supported MCP runtime found. Install Node.js 18+ or Bun 1+ and ensure it's on PATH."
|
||||
.into()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Find the `node` binary specifically. Used by Codex/CLI agent shims that
|
||||
/// require Node and cannot fall back to Bun.
|
||||
pub(crate) fn find_node() -> Result<PathBuf, String> {
|
||||
let mut last_error = None;
|
||||
if let Some(binary) = try_runtime(McpRuntimeKind::Node, &mut last_error) {
|
||||
return Ok(binary);
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
format!(
|
||||
"{} not found in PATH or common install locations",
|
||||
McpRuntimeKind::Node.binary_name()
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fn try_runtime(kind: McpRuntimeKind, last_error: &mut Option<String>) -> Option<PathBuf> {
|
||||
for path in runtime_binary_candidates(kind) {
|
||||
match verify_runtime_version(kind, &path) {
|
||||
Ok(()) => return Some(path),
|
||||
Err(error) => *last_error = Some(error),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn runtime_binary_candidates(kind: McpRuntimeKind) -> Vec<PathBuf> {
|
||||
let command = kind.binary_name();
|
||||
let mut candidates = find_on_path(command);
|
||||
candidates.extend(find_in_user_shell(command));
|
||||
candidates.extend(fallback_paths_for(kind));
|
||||
candidates
|
||||
}
|
||||
|
||||
fn find_node_on_path() -> Vec<PathBuf> {
|
||||
node_lookup_command()
|
||||
fn fallback_paths_for(kind: McpRuntimeKind) -> Vec<PathBuf> {
|
||||
match kind {
|
||||
McpRuntimeKind::Node => fallback_node_paths(),
|
||||
McpRuntimeKind::Bun => fallback_bun_paths(),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_runtime_version(kind: McpRuntimeKind, path: &Path) -> Result<(), String> {
|
||||
match kind {
|
||||
McpRuntimeKind::Node => verify_node_version(path),
|
||||
McpRuntimeKind::Bun => verify_bun_version(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_on_path(command: &str) -> Vec<PathBuf> {
|
||||
lookup_command(command)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| node_lookup_paths(&output.stdout))
|
||||
.map(|output| lookup_paths(&output.stdout))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn find_node_in_user_shell() -> Vec<PathBuf> {
|
||||
fn find_in_user_shell(command: &str) -> Vec<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.filter_map(|shell| command_path_from_shell(&shell, "node"))
|
||||
.filter_map(|shell| command_path_from_shell(&shell, command))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn node_lookup_paths(stdout: &[u8]) -> Vec<PathBuf> {
|
||||
fn lookup_paths(stdout: &[u8]) -> Vec<PathBuf> {
|
||||
String::from_utf8_lossy(stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
@@ -142,14 +212,14 @@ fn node_major_version(version: &str) -> Option<u32> {
|
||||
.and_then(|major| major.parse().ok())
|
||||
}
|
||||
|
||||
fn node_lookup_command() -> Command {
|
||||
fn lookup_command(command: &str) -> Command {
|
||||
#[cfg(windows)]
|
||||
let mut command = subprocess::command("where.exe");
|
||||
let mut cmd = subprocess::command("where.exe");
|
||||
#[cfg(not(windows))]
|
||||
let mut command = subprocess::command("which");
|
||||
let mut cmd = subprocess::command("which");
|
||||
|
||||
command.arg("node");
|
||||
command
|
||||
cmd.arg(command);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn fallback_node_paths() -> Vec<PathBuf> {
|
||||
@@ -158,6 +228,9 @@ fn fallback_node_paths() -> Vec<PathBuf> {
|
||||
PathBuf::from("/usr/local/bin/node"),
|
||||
];
|
||||
|
||||
#[cfg(not(windows))]
|
||||
candidates.push(PathBuf::from("/home/linuxbrew/.linuxbrew/bin/node"));
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(program_files) = std::env::var_os("ProgramFiles") {
|
||||
@@ -197,6 +270,7 @@ fn node_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
home.join(".mise").join("shims").join(node_binary_name()),
|
||||
home.join(".asdf").join("shims").join(node_binary_name()),
|
||||
home.join(".volta").join("bin").join(node_binary_name()),
|
||||
home.join(".linuxbrew").join("bin").join(node_binary_name()),
|
||||
];
|
||||
|
||||
let nvm_dir = home.join(".nvm").join("versions").join("node");
|
||||
@@ -224,6 +298,81 @@ fn node_binary_name() -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_bun_paths() -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
PathBuf::from("/opt/homebrew/bin/bun"),
|
||||
PathBuf::from("/usr/local/bin/bun"),
|
||||
];
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(profile) = std::env::var_os("USERPROFILE") {
|
||||
candidates.push(
|
||||
PathBuf::from(profile)
|
||||
.join(".bun")
|
||||
.join("bin")
|
||||
.join("bun.exe"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.extend(bun_binary_candidates_for_home(&home));
|
||||
}
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|path| path.is_file())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bun_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".bun").join("bin").join(bun_binary_name()),
|
||||
home.join(".local/share/mise/shims").join(bun_binary_name()),
|
||||
home.join(".mise").join("shims").join(bun_binary_name()),
|
||||
home.join(".asdf").join("shims").join(bun_binary_name()),
|
||||
home.join(".proto").join("bin").join(bun_binary_name()),
|
||||
]
|
||||
}
|
||||
|
||||
fn bun_binary_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"bun.exe"
|
||||
} else {
|
||||
"bun"
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_bun_version(bun: &Path) -> Result<(), String> {
|
||||
let output = subprocess::command(bun)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run {} --version: {e}", bun.display()))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"{} --version failed; install Bun 1+ and make it available on PATH",
|
||||
bun.display()
|
||||
));
|
||||
}
|
||||
|
||||
let raw_version = String::from_utf8_lossy(&output.stdout);
|
||||
let Some(major) = node_major_version(&raw_version) else {
|
||||
return Err(format!(
|
||||
"Cannot parse Bun version from '{}'",
|
||||
raw_version.trim()
|
||||
));
|
||||
};
|
||||
if major < 1 {
|
||||
return Err(format!(
|
||||
"Bun 1+ is required for Tolaria MCP tools; found {}",
|
||||
raw_version.trim()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the path to `mcp-server/`.
|
||||
///
|
||||
/// In dev mode, prefers `CARGO_MANIFEST_DIR` and falls back to runtime checkout ancestors.
|
||||
@@ -249,6 +398,15 @@ pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
|
||||
))
|
||||
}
|
||||
|
||||
fn mcp_server_dir_for_registration() -> Result<PathBuf, String> {
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
if let Some(stable_dir) = extraction::ready_stable_mcp_server_dir() {
|
||||
return Ok(stable_dir);
|
||||
}
|
||||
|
||||
mcp_server_dir()
|
||||
}
|
||||
|
||||
fn build_time_dev_mcp_server_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
@@ -392,13 +550,13 @@ pub fn spawn_ws_bridge_with_paths(
|
||||
vault_path: impl AsRef<Path>,
|
||||
vault_paths: &[PathBuf],
|
||||
) -> Result<Child, String> {
|
||||
let node = find_node()?;
|
||||
let runtime = find_mcp_runtime()?;
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let script = server_dir.join("ws-bridge.js");
|
||||
let vault_path = vault_path.as_ref();
|
||||
let active_vault_paths = active_vault_paths_json(vault_path, vault_paths);
|
||||
|
||||
let mut command = subprocess::command(node);
|
||||
let mut command = subprocess::command(&runtime.binary);
|
||||
let child = command
|
||||
.arg(&script)
|
||||
.env("VAULT_PATH", vault_path)
|
||||
@@ -412,8 +570,9 @@ pub fn spawn_ws_bridge_with_paths(
|
||||
.map_err(|e| format!("Failed to spawn ws-bridge: {e}"))?;
|
||||
|
||||
log::info!(
|
||||
"ws-bridge spawned (pid: {}, vault: {})",
|
||||
"ws-bridge spawned (pid: {}, runtime: {:?}, vault: {})",
|
||||
child.id(),
|
||||
runtime.kind,
|
||||
vault_path.display()
|
||||
);
|
||||
Ok(child)
|
||||
@@ -482,29 +641,13 @@ 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;
|
||||
};
|
||||
|
||||
let Ok(expected) = std::fs::canonicalize(vault_path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(actual) = std::fs::canonicalize(entry_vault_path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
actual == expected
|
||||
}
|
||||
|
||||
/// Build the MCP server entry JSON for a given vault path and index.js path.
|
||||
fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serde_json::Value {
|
||||
/// Build the durable external MCP server entry JSON for an index.js path.
|
||||
fn build_mcp_entry(runtime_command: &str, index_js: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "stdio",
|
||||
"command": node_command,
|
||||
"command": runtime_command,
|
||||
"args": [index_js],
|
||||
"env": {
|
||||
"VAULT_PATH": vault_path,
|
||||
"WS_UI_PORT": "9711"
|
||||
}
|
||||
})
|
||||
@@ -521,13 +664,16 @@ fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result<String, String>
|
||||
|
||||
/// Build the exact MCP config JSON users can copy into compatible tools.
|
||||
pub fn mcp_config_snippet(vault_path: &str) -> Result<String, String> {
|
||||
let node = find_node().map_err(|e| {
|
||||
format!("Node.js 18+ is required on PATH before Tolaria can build MCP config: {e}")
|
||||
let _ = vault_path;
|
||||
let runtime = find_mcp_runtime().map_err(|e| {
|
||||
format!(
|
||||
"Node.js 18+ or Bun 1+ is required on PATH before Tolaria can build MCP config: {e}"
|
||||
)
|
||||
})?;
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let server_dir = mcp_server_dir_for_registration()?;
|
||||
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
|
||||
let node_command = node.to_string_lossy().into_owned();
|
||||
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
|
||||
let runtime_command = runtime.binary.to_string_lossy().into_owned();
|
||||
let entry = build_mcp_entry(&runtime_command, &index_js);
|
||||
|
||||
build_mcp_config_snippet(&entry)
|
||||
}
|
||||
@@ -548,14 +694,23 @@ 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 _ = vault_path;
|
||||
let runtime = find_mcp_runtime().map_err(|e| {
|
||||
format!(
|
||||
"Node.js 18+ or Bun 1+ is required on PATH before Tolaria can register MCP tools: {e}"
|
||||
)
|
||||
})?;
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let server_dir = mcp_server_dir_for_registration()?;
|
||||
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
|
||||
let node_command = node.to_string_lossy().into_owned();
|
||||
let runtime_command = runtime.binary.to_string_lossy().into_owned();
|
||||
|
||||
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
|
||||
let entry = build_mcp_entry(&runtime_command, &index_js);
|
||||
let opencode_entry = opencode::build_entry(&runtime_command, &index_js);
|
||||
if let Some(config_path) = opencode::config_path() {
|
||||
if let Err(e) = opencode::upsert_config(&config_path, &opencode_entry) {
|
||||
log::warn!("Failed to update {}: {}", config_path.display(), e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(register_mcp_to_configs(&entry, &mcp_config_paths()))
|
||||
}
|
||||
@@ -657,7 +812,19 @@ fn remove_mcp_from_config(config_path: &Path) -> Result<bool, String> {
|
||||
}
|
||||
|
||||
pub fn remove_mcp() -> String {
|
||||
remove_mcp_from_configs(&mcp_config_paths())
|
||||
let removed_standard = remove_mcp_from_configs(&mcp_config_paths()) == "removed";
|
||||
let removed_opencode = opencode::config_path().is_some_and(|config_path| {
|
||||
opencode::remove_config(&config_path).unwrap_or_else(|e| {
|
||||
log::warn!("Failed to update {}: {}", config_path.display(), e);
|
||||
false
|
||||
})
|
||||
});
|
||||
|
||||
if removed_standard || removed_opencode {
|
||||
"removed".to_string()
|
||||
} else {
|
||||
"already_absent".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the MCP server is properly installed and registered.
|
||||
@@ -666,15 +833,18 @@ pub fn remove_mcp() -> String {
|
||||
/// an external AI tool config and the referenced index.js file is present.
|
||||
/// Otherwise returns `NotInstalled`.
|
||||
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| {
|
||||
let _ = vault_path;
|
||||
let installed_standard = mcp_config_paths().into_iter().any(|config_path| {
|
||||
read_registered_mcp_entry(&config_path).is_some_and(|entry| {
|
||||
entry_uses_stdio(&entry)
|
||||
&& entry_index_js_exists(&entry)
|
||||
&& entry_has_ui_port(&entry)
|
||||
&& entry_targets_vault(&entry, active_vault_path)
|
||||
entry_uses_stdio(&entry) && entry_index_js_exists(&entry) && entry_has_ui_port(&entry)
|
||||
})
|
||||
}) {
|
||||
});
|
||||
let installed_opencode = opencode::config_path().is_some_and(|config_path| {
|
||||
opencode::read_registered_entry(&config_path)
|
||||
.is_some_and(|entry| opencode::entry_is_installed(&entry))
|
||||
});
|
||||
|
||||
if installed_standard || installed_opencode {
|
||||
McpStatus::Installed
|
||||
} else {
|
||||
McpStatus::NotInstalled
|
||||
@@ -700,17 +870,17 @@ mod tests {
|
||||
std::fs::write(config_path, serde_json::to_string(&config).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn managed_server(index_js: &str, vault_path: &str) -> serde_json::Value {
|
||||
fn managed_server(index_js: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": [index_js],
|
||||
"env": { "VAULT_PATH": vault_path, "WS_UI_PORT": "9711" }
|
||||
"env": { "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 test_mcp_entry(index_js: &str) -> serde_json::Value {
|
||||
build_mcp_entry("node", index_js)
|
||||
}
|
||||
|
||||
fn write_mcp_servers_config(config_path: &Path, servers: Vec<(&str, serde_json::Value)>) {
|
||||
@@ -723,7 +893,6 @@ mod tests {
|
||||
|
||||
struct ExpectedMcpServer<'a> {
|
||||
index_js: &'a str,
|
||||
vault_path: &'a str,
|
||||
}
|
||||
|
||||
fn assert_registered_tolaria_server(
|
||||
@@ -732,7 +901,8 @@ mod tests {
|
||||
) {
|
||||
let server = &config["mcpServers"][MCP_SERVER_NAME];
|
||||
assert_eq!(server["args"][0], expected.index_js);
|
||||
assert_eq!(server["env"]["VAULT_PATH"], expected.vault_path);
|
||||
assert!(server["env"]["VAULT_PATH"].is_null());
|
||||
assert_eq!(server["env"]["WS_UI_PORT"], "9711");
|
||||
}
|
||||
|
||||
fn write_index_js(dir: &Path) -> PathBuf {
|
||||
@@ -743,7 +913,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_mcp_entry_produces_correct_json() {
|
||||
let entry = build_mcp_entry("/usr/local/bin/node", "/path/to/index.js", "/my/vault");
|
||||
let entry = build_mcp_entry("/usr/local/bin/node", "/path/to/index.js");
|
||||
assert_eq!(
|
||||
entry,
|
||||
serde_json::json!({
|
||||
@@ -751,7 +921,6 @@ mod tests {
|
||||
"command": "/usr/local/bin/node",
|
||||
"args": ["/path/to/index.js"],
|
||||
"env": {
|
||||
"VAULT_PATH": "/my/vault",
|
||||
"WS_UI_PORT": "9711"
|
||||
}
|
||||
})
|
||||
@@ -760,7 +929,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_mcp_config_snippet_wraps_tolaria_server_entry() {
|
||||
let entry = test_mcp_entry("/path/to/index.js", "/my/vault");
|
||||
let entry = test_mcp_entry("/path/to/index.js");
|
||||
let snippet = build_mcp_config_snippet(&entry).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&snippet).unwrap();
|
||||
|
||||
@@ -768,17 +937,14 @@ mod tests {
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/path/to/index.js"
|
||||
);
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
|
||||
"/my/vault"
|
||||
);
|
||||
assert!(config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_lookup_paths_keep_non_empty_lines_in_order() {
|
||||
fn lookup_paths_keep_non_empty_lines_in_order() {
|
||||
let stdout = b"\nC:\\Program Files\\nodejs\\node.exe\r\nC:\\Other\\node.exe\r\n";
|
||||
assert_eq!(
|
||||
node_lookup_paths(stdout),
|
||||
lookup_paths(stdout),
|
||||
vec![
|
||||
PathBuf::from("C:\\Program Files\\nodejs\\node.exe"),
|
||||
PathBuf::from("C:\\Other\\node.exe"),
|
||||
@@ -837,6 +1003,7 @@ mod tests {
|
||||
home.join(".local/share/mise/shims/node"),
|
||||
home.join(".asdf/shims/node"),
|
||||
home.join(".volta/bin/node"),
|
||||
home.join(".linuxbrew/bin/node"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
@@ -946,7 +1113,7 @@ mod tests {
|
||||
fn upsert_creates_new_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
let entry = test_mcp_entry("/test/index.js", "/test/vault");
|
||||
let entry = test_mcp_entry("/test/index.js");
|
||||
|
||||
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(!was_update);
|
||||
@@ -956,7 +1123,6 @@ mod tests {
|
||||
&config,
|
||||
ExpectedMcpServer {
|
||||
index_js: "/test/index.js",
|
||||
vault_path: "/test/vault",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -966,18 +1132,15 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
|
||||
let entry1 = test_mcp_entry("/test/index.js", "/vault/v1");
|
||||
let entry1 = test_mcp_entry("/test/index.js");
|
||||
upsert_mcp_config(&config_path, &entry1).unwrap();
|
||||
|
||||
let entry2 = test_mcp_entry("/test/index.js", "/vault/v2");
|
||||
let entry2 = test_mcp_entry("/test/index.js");
|
||||
let was_update = upsert_mcp_config(&config_path, &entry2).unwrap();
|
||||
assert!(was_update);
|
||||
|
||||
let config = read_config(&config_path);
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
|
||||
"/vault/v2"
|
||||
);
|
||||
assert!(config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -996,7 +1159,7 @@ mod tests {
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
|
||||
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js");
|
||||
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(was_update);
|
||||
|
||||
@@ -1019,7 +1182,7 @@ mod tests {
|
||||
)],
|
||||
);
|
||||
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js");
|
||||
upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
@@ -1042,7 +1205,7 @@ mod tests {
|
||||
}),
|
||||
);
|
||||
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js");
|
||||
upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
|
||||
let config = read_config(&config_path);
|
||||
@@ -1069,7 +1232,7 @@ mod tests {
|
||||
}
|
||||
}),
|
||||
);
|
||||
let entry = test_mcp_entry("/gemini/index.js", "/gemini-vault");
|
||||
let entry = test_mcp_entry("/gemini/index.js");
|
||||
|
||||
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
let config = read_config(&config_path);
|
||||
@@ -1081,7 +1244,6 @@ mod tests {
|
||||
&config,
|
||||
ExpectedMcpServer {
|
||||
index_js: "/gemini/index.js",
|
||||
vault_path: "/gemini-vault",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1090,7 +1252,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 = test_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js");
|
||||
|
||||
upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(config_path.exists());
|
||||
@@ -1100,7 +1262,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 = test_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js");
|
||||
|
||||
let status = register_mcp_to_configs(&entry, &[config]);
|
||||
assert_eq!(status, "registered");
|
||||
@@ -1110,7 +1272,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 = test_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js");
|
||||
|
||||
// First call
|
||||
register_mcp_to_configs(&entry, std::slice::from_ref(&config));
|
||||
@@ -1130,6 +1292,63 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_mcp_runtime_returns_valid_runtime() {
|
||||
let runtime = find_mcp_runtime().unwrap();
|
||||
assert!(
|
||||
runtime.binary.exists(),
|
||||
"runtime binary should exist at {:?}",
|
||||
runtime.binary
|
||||
);
|
||||
let expected = match runtime.kind {
|
||||
McpRuntimeKind::Node => "node",
|
||||
McpRuntimeKind::Bun => "bun",
|
||||
};
|
||||
assert!(
|
||||
runtime.binary.to_string_lossy().contains(expected),
|
||||
"path should contain '{expected}': {:?}",
|
||||
runtime.binary
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bun_binary_candidates_include_shell_managed_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = bun_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".bun/bin/bun"),
|
||||
home.join(".local/share/mise/shims/bun"),
|
||||
home.join(".mise/shims/bun"),
|
||||
home.join(".asdf/shims/bun"),
|
||||
home.join(".proto/bin/bun"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_bun_version_accepts_real_bun_binary() {
|
||||
let Ok(bun) = find_bun_for_test() else {
|
||||
// Bun is optional on dev machines; skip when absent.
|
||||
return;
|
||||
};
|
||||
verify_bun_version(&bun).expect("installed bun should satisfy version requirement");
|
||||
}
|
||||
|
||||
fn find_bun_for_test() -> Result<PathBuf, String> {
|
||||
let mut last_error = None;
|
||||
if let Some(bin) = try_runtime(McpRuntimeKind::Bun, &mut last_error) {
|
||||
return Ok(bin);
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| "bun not present in test environment".into()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_dir_resolves_in_dev() {
|
||||
let dir = mcp_server_dir().unwrap();
|
||||
@@ -1159,7 +1378,7 @@ mod tests {
|
||||
let gemini_cfg = tmp.path().join(".gemini").join("settings.json");
|
||||
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
|
||||
let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js");
|
||||
|
||||
register_mcp_to_configs(
|
||||
&entry,
|
||||
@@ -1187,7 +1406,6 @@ mod tests {
|
||||
&config,
|
||||
ExpectedMcpServer {
|
||||
index_js: "/test/index.js",
|
||||
vault_path: "/vault",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1213,14 +1431,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 = test_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js");
|
||||
let result = upsert_mcp_config(&config_path, &entry);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_mcp_to_configs_handles_empty_list() {
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js");
|
||||
// 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"
|
||||
@@ -1233,19 +1451,13 @@ mod tests {
|
||||
write_mcp_servers_config(
|
||||
&config_path,
|
||||
vec![
|
||||
(
|
||||
MCP_SERVER_NAME,
|
||||
managed_server("/primary/index.js", "/primary"),
|
||||
),
|
||||
(
|
||||
LEGACY_MCP_SERVER_NAME,
|
||||
managed_server("/legacy/index.js", "/legacy"),
|
||||
),
|
||||
(MCP_SERVER_NAME, managed_server("/primary/index.js")),
|
||||
(LEGACY_MCP_SERVER_NAME, managed_server("/legacy/index.js")),
|
||||
],
|
||||
);
|
||||
|
||||
let entry = read_registered_mcp_entry(&config_path).unwrap();
|
||||
assert_eq!(entry["env"]["VAULT_PATH"], "/primary");
|
||||
assert_eq!(entry["args"][0], "/primary/index.js");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1253,14 +1465,11 @@ mod tests {
|
||||
let (_tmp, config_path) = temp_config_path("mcp.json");
|
||||
write_mcp_servers_config(
|
||||
&config_path,
|
||||
vec![(
|
||||
LEGACY_MCP_SERVER_NAME,
|
||||
managed_server("/legacy/index.js", "/legacy"),
|
||||
)],
|
||||
vec![(LEGACY_MCP_SERVER_NAME, managed_server("/legacy/index.js"))],
|
||||
);
|
||||
|
||||
let entry = read_registered_mcp_entry(&config_path).unwrap();
|
||||
assert_eq!(entry["env"]["VAULT_PATH"], "/legacy");
|
||||
assert_eq!(entry["args"][0], "/legacy/index.js");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1305,7 +1514,7 @@ mod tests {
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
std::fs::write(&config_path, "[]").unwrap();
|
||||
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js");
|
||||
let result = upsert_mcp_config(&config_path, &entry);
|
||||
assert!(matches!(result, Err(ref error) if error.contains("Config is not a JSON object")));
|
||||
}
|
||||
@@ -1319,7 +1528,7 @@ mod tests {
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
|
||||
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js");
|
||||
let result = upsert_mcp_config(&config_path, &entry);
|
||||
assert!(
|
||||
matches!(result, Err(ref error) if error.contains("mcpServers is not a JSON object"))
|
||||
@@ -1364,42 +1573,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_mcp_status_returns_installed_for_matching_vault() {
|
||||
fn durable_entry_is_status_eligible_without_vault_env() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let vault_path = tmp.path().join("vault");
|
||||
std::fs::create_dir_all(&vault_path).unwrap();
|
||||
let index_js = write_index_js(tmp.path());
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
let config = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"tolaria": {
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": [index_js.to_string_lossy()],
|
||||
"env": { "VAULT_PATH": vault_path.to_string_lossy() }
|
||||
"env": { "WS_UI_PORT": "9711" }
|
||||
}
|
||||
}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
|
||||
|
||||
let entry = read_registered_mcp_entry(&config_path).unwrap();
|
||||
assert!(entry_targets_vault(&entry, &vault_path));
|
||||
assert!(entry_uses_stdio(&entry));
|
||||
assert!(entry_index_js_exists(&entry));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_targets_vault_requires_matching_existing_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let first_vault = tmp.path().join("vault-a");
|
||||
let second_vault = tmp.path().join("vault-b");
|
||||
std::fs::create_dir_all(&first_vault).unwrap();
|
||||
std::fs::create_dir_all(&second_vault).unwrap();
|
||||
|
||||
let entry = serde_json::json!({
|
||||
"env": { "VAULT_PATH": first_vault.to_string_lossy() }
|
||||
});
|
||||
|
||||
assert!(entry_targets_vault(&entry, &first_vault));
|
||||
assert!(!entry_targets_vault(&entry, &second_vault));
|
||||
assert!(entry_has_ui_port(&entry));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
303
src-tauri/src/mcp/extraction.rs
Normal file
303
src-tauri/src/mcp/extraction.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
use std::fs;
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
const VERSION_MARKER_FILE: &str = ".tolaria-version";
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
const LOCK_FILE: &str = "mcp-server.lock";
|
||||
const STAGING_DIR: &str = "mcp-server.staging";
|
||||
const BACKUP_DIR: &str = "mcp-server.previous";
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
const STALE_LOCK_AFTER: Duration = Duration::from_secs(120);
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
pub(super) fn ready_stable_mcp_server_dir() -> Option<PathBuf> {
|
||||
let stable_dir = stable_mcp_server_dir().ok()?;
|
||||
stable_mcp_server_dir_is_ready(&stable_dir).then_some(stable_dir)
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
pub(crate) fn extract_mcp_server_to_stable_dir(app_version: &str) -> Result<PathBuf, String> {
|
||||
let source_dir = super::mcp_server_dir()?;
|
||||
let target_dir = stable_mcp_server_dir()?;
|
||||
|
||||
if !needs_extraction(app_version, &target_dir) {
|
||||
return Ok(target_dir);
|
||||
}
|
||||
|
||||
let _lock = ExtractionLock::acquire(&extraction_lock_path()?)?;
|
||||
if !needs_extraction(app_version, &target_dir) {
|
||||
return Ok(target_dir);
|
||||
}
|
||||
|
||||
replace_stable_server_dir(&source_dir, &target_dir, app_version)?;
|
||||
Ok(target_dir)
|
||||
}
|
||||
|
||||
fn stable_mcp_server_dir() -> Result<PathBuf, String> {
|
||||
dirs::data_dir()
|
||||
.map(|data_dir| data_dir.join("tolaria").join("mcp-server"))
|
||||
.ok_or_else(|| "Unable to resolve data directory for stable MCP server path".to_string())
|
||||
}
|
||||
|
||||
fn stable_mcp_server_dir_is_ready(dir: &Path) -> bool {
|
||||
mcp_server_dir_has_files(dir) && read_version_marker(dir).is_some()
|
||||
}
|
||||
|
||||
fn mcp_server_dir_has_files(dir: &Path) -> bool {
|
||||
dir.join("index.js").is_file() && dir.join("ws-bridge.js").is_file()
|
||||
}
|
||||
|
||||
fn needs_extraction(app_version: &str, target_dir: &Path) -> bool {
|
||||
!mcp_server_dir_has_files(target_dir)
|
||||
|| read_version_marker(target_dir).as_deref() != Some(app_version)
|
||||
}
|
||||
|
||||
fn read_version_marker(dir: &Path) -> Option<String> {
|
||||
fs::read_to_string(dir.join(VERSION_MARKER_FILE))
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn write_version_marker(dir: &Path, app_version: &str) -> Result<(), String> {
|
||||
let marker = dir.join(VERSION_MARKER_FILE);
|
||||
fs::write(&marker, app_version)
|
||||
.map_err(|e| format!("Failed to write version marker {}: {e}", marker.display()))
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn extraction_lock_path() -> Result<PathBuf, String> {
|
||||
let stable_dir = stable_mcp_server_dir()?;
|
||||
stable_dir
|
||||
.parent()
|
||||
.map(|parent| parent.join(LOCK_FILE))
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Stable MCP server path has no parent: {}",
|
||||
stable_dir.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn replace_stable_server_dir(
|
||||
source_dir: &Path,
|
||||
target_dir: &Path,
|
||||
app_version: &str,
|
||||
) -> Result<(), String> {
|
||||
let parent = target_dir.parent().ok_or_else(|| {
|
||||
format!(
|
||||
"Stable MCP server path has no parent: {}",
|
||||
target_dir.display()
|
||||
)
|
||||
})?;
|
||||
let staging_dir = parent.join(STAGING_DIR);
|
||||
let backup_dir = parent.join(BACKUP_DIR);
|
||||
|
||||
remove_dir_if_exists(&staging_dir)?;
|
||||
remove_dir_if_exists(&backup_dir)?;
|
||||
copy_dir_all(source_dir, &staging_dir)?;
|
||||
write_version_marker(&staging_dir, app_version)?;
|
||||
swap_staging_into_place(&staging_dir, target_dir, &backup_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn swap_staging_into_place(
|
||||
staging_dir: &Path,
|
||||
target_dir: &Path,
|
||||
backup_dir: &Path,
|
||||
) -> Result<(), String> {
|
||||
if target_dir.exists() {
|
||||
fs::rename(target_dir, backup_dir)
|
||||
.map_err(|e| format!("Failed to move stable MCP server aside: {e}"))?;
|
||||
}
|
||||
|
||||
if let Err(error) = fs::rename(staging_dir, target_dir) {
|
||||
if backup_dir.exists() {
|
||||
let _ = fs::rename(backup_dir, target_dir);
|
||||
}
|
||||
return Err(format!("Failed to activate stable MCP server: {error}"));
|
||||
}
|
||||
|
||||
remove_dir_if_exists(backup_dir)
|
||||
}
|
||||
|
||||
fn copy_dir_all(source: &Path, target: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(target)
|
||||
.map_err(|e| format!("Failed to create {}: {e}", target.display()))?;
|
||||
|
||||
for entry in fs::read_dir(source).map_err(|e| {
|
||||
format!(
|
||||
"Failed to read MCP server directory {}: {e}",
|
||||
source.display()
|
||||
)
|
||||
})? {
|
||||
let entry = entry.map_err(|e| format!("Failed to read MCP server entry: {e}"))?;
|
||||
let source_path = entry.path();
|
||||
let target_path = target.join(entry.file_name());
|
||||
if source_path.is_dir() {
|
||||
copy_dir_all(&source_path, &target_path)?;
|
||||
} else {
|
||||
fs::copy(&source_path, &target_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to copy {} to {}: {e}",
|
||||
source_path.display(),
|
||||
target_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_dir_if_exists(path: &Path) -> Result<(), String> {
|
||||
if path.exists() {
|
||||
fs::remove_dir_all(path)
|
||||
.map_err(|e| format!("Failed to remove {}: {e}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
struct ExtractionLock {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
impl ExtractionLock {
|
||||
fn acquire(path: &Path) -> Result<Self, String> {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
match Self::try_create(path) {
|
||||
Ok(()) => {
|
||||
return Ok(Self {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
Err(error) if lock_is_stale(path) => {
|
||||
let _ = fs::remove_file(path);
|
||||
log::warn!("Removed stale MCP extraction lock after error: {error}");
|
||||
}
|
||||
Err(error) if started.elapsed() >= LOCK_TIMEOUT => return Err(error),
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(50)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_create(path: &Path) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create MCP extraction lock dir: {e}"))?;
|
||||
}
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
.map_err(|e| format!("Failed to acquire MCP extraction lock: {e}"))?;
|
||||
use std::io::Write;
|
||||
writeln!(file, "{}", std::process::id())
|
||||
.map_err(|e| format!("Failed to write MCP extraction lock: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
impl Drop for ExtractionLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn lock_is_stale(path: &Path) -> bool {
|
||||
path.metadata()
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.ok()
|
||||
.and_then(|modified| SystemTime::now().duration_since(modified).ok())
|
||||
.is_some_and(|age| age >= STALE_LOCK_AFTER)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_server_dir(parent: &Path) -> PathBuf {
|
||||
let dir = parent.join("server");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("index.js"), "console.log('index');").unwrap();
|
||||
fs::write(dir.join("ws-bridge.js"), "console.log('bridge');").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_mcp_server_dir_uses_app_data_dir() {
|
||||
let expected = dirs::data_dir()
|
||||
.expect("data dir should exist")
|
||||
.join("tolaria")
|
||||
.join("mcp-server");
|
||||
|
||||
assert_eq!(stable_mcp_server_dir().unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_mcp_server_dir_requires_marker_and_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let stable_dir = tmp.path().join("tolaria").join("mcp-server");
|
||||
|
||||
fs::create_dir_all(&stable_dir).unwrap();
|
||||
fs::write(stable_dir.join("index.js"), "").unwrap();
|
||||
fs::write(stable_dir.join("ws-bridge.js"), "").unwrap();
|
||||
assert!(!stable_mcp_server_dir_is_ready(&stable_dir));
|
||||
|
||||
write_version_marker(&stable_dir, "2026.5.14").unwrap();
|
||||
assert!(stable_mcp_server_dir_is_ready(&stable_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_extraction_tracks_version_marker() {
|
||||
let target = tempfile::tempdir().unwrap();
|
||||
fs::write(target.path().join("index.js"), "").unwrap();
|
||||
fs::write(target.path().join("ws-bridge.js"), "").unwrap();
|
||||
|
||||
assert!(needs_extraction("2026.5.14", target.path()));
|
||||
write_version_marker(target.path(), "2026.5.14").unwrap();
|
||||
assert!(!needs_extraction("2026.5.14", target.path()));
|
||||
assert!(needs_extraction("2026.5.15", target.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_dir_all_copies_nested_server_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let source = create_server_dir(tmp.path());
|
||||
fs::create_dir_all(source.join("nested")).unwrap();
|
||||
fs::write(source.join("nested").join("package.json"), "{}").unwrap();
|
||||
let target = tmp.path().join("target");
|
||||
|
||||
copy_dir_all(&source, &target).unwrap();
|
||||
|
||||
assert!(target.join("index.js").is_file());
|
||||
assert!(target.join("ws-bridge.js").is_file());
|
||||
assert!(target.join("nested").join("package.json").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_stable_server_dir_swaps_versioned_copy() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let source = create_server_dir(tmp.path());
|
||||
let target = tmp.path().join("tolaria").join("mcp-server");
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
fs::write(target.join("stale.txt"), "old").unwrap();
|
||||
|
||||
replace_stable_server_dir(&source, &target, "2026.5.14").unwrap();
|
||||
|
||||
assert!(target.join("index.js").is_file());
|
||||
assert!(!target.join("stale.txt").exists());
|
||||
assert_eq!(read_version_marker(&target), Some("2026.5.14".to_string()));
|
||||
}
|
||||
}
|
||||
241
src-tauri/src/mcp/opencode.rs
Normal file
241
src-tauri/src/mcp/opencode.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::{LEGACY_MCP_SERVER_NAME, MCP_SERVER_NAME};
|
||||
|
||||
const OPENCODE_MCP_KEY: &str = "mcp";
|
||||
|
||||
pub(super) fn config_path() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|config_dir| config_dir.join("opencode").join("opencode.json"))
|
||||
}
|
||||
|
||||
pub(super) fn build_entry(node_command: &str, index_js: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "local",
|
||||
"command": [node_command, index_js],
|
||||
"enabled": true,
|
||||
"environment": {
|
||||
"WS_UI_PORT": "9711"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn upsert_config(config_path: &Path, entry: &Value) -> Result<bool, String> {
|
||||
let mut config = read_config_or_empty(config_path)?;
|
||||
let servers = ensure_servers_object(&mut config)?;
|
||||
let was_update =
|
||||
servers.get(MCP_SERVER_NAME).is_some() || servers.get(LEGACY_MCP_SERVER_NAME).is_some();
|
||||
|
||||
servers.remove(LEGACY_MCP_SERVER_NAME);
|
||||
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
|
||||
write_config(config_path, &config)?;
|
||||
Ok(was_update)
|
||||
}
|
||||
|
||||
pub(super) fn remove_config(config_path: &Path) -> Result<bool, String> {
|
||||
if !config_path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut config = read_config_or_empty(config_path)?;
|
||||
let Some(config_object) = config.as_object_mut() else {
|
||||
return Err("Config is not a JSON object".into());
|
||||
};
|
||||
let Some(servers_value) = config_object.get_mut(OPENCODE_MCP_KEY) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(servers) = servers_value.as_object_mut() else {
|
||||
return Err("mcp is not a JSON object".into());
|
||||
};
|
||||
|
||||
let removed_primary = servers.remove(MCP_SERVER_NAME).is_some();
|
||||
let removed_legacy = servers.remove(LEGACY_MCP_SERVER_NAME).is_some();
|
||||
if !removed_primary && !removed_legacy {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if servers.is_empty() {
|
||||
config_object.remove(OPENCODE_MCP_KEY);
|
||||
}
|
||||
write_config(config_path, &config)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn read_registered_entry(config_path: &Path) -> Option<Value> {
|
||||
let raw = std::fs::read_to_string(config_path).ok()?;
|
||||
let config: Value = serde_json::from_str(&raw).ok()?;
|
||||
config
|
||||
.get(OPENCODE_MCP_KEY)
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|servers| {
|
||||
servers
|
||||
.get(MCP_SERVER_NAME)
|
||||
.or_else(|| servers.get(LEGACY_MCP_SERVER_NAME))
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(super) fn entry_is_installed(entry: &Value) -> bool {
|
||||
entry["type"].as_str() == Some("local")
|
||||
&& entry["enabled"].as_bool() == Some(true)
|
||||
&& entry["environment"]["WS_UI_PORT"].as_str() == Some("9711")
|
||||
&& command_index_js_exists(entry)
|
||||
}
|
||||
|
||||
fn command_index_js_exists(entry: &Value) -> bool {
|
||||
entry["command"]
|
||||
.as_array()
|
||||
.and_then(|command| command.get(1))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|index_js| Path::new(index_js).exists())
|
||||
}
|
||||
|
||||
fn read_config_or_empty(config_path: &Path) -> Result<Value, String> {
|
||||
if !config_path.exists() {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
|
||||
let raw = std::fs::read_to_string(config_path)
|
||||
.map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?;
|
||||
serde_json::from_str(&raw)
|
||||
.map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display()))
|
||||
}
|
||||
|
||||
fn ensure_servers_object(config: &mut Value) -> Result<&mut Map<String, Value>, String> {
|
||||
let servers = config
|
||||
.as_object_mut()
|
||||
.ok_or("Config is not a JSON object")?
|
||||
.entry(OPENCODE_MCP_KEY)
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
|
||||
servers
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "mcp is not a JSON object".to_string())
|
||||
}
|
||||
|
||||
fn write_config(config_path: &Path, config: &Value) -> Result<(), String> {
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(config)
|
||||
.map_err(|e| format!("Failed to serialize config: {e}"))?;
|
||||
std::fs::write(config_path, json)
|
||||
.map_err(|e| format!("Cannot write {}: {e}", config_path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn read_config(config_path: &Path) -> Value {
|
||||
let raw = std::fs::read_to_string(config_path).unwrap();
|
||||
serde_json::from_str(&raw).unwrap()
|
||||
}
|
||||
|
||||
fn write_config_json(config_path: &Path, config: Value) {
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(config_path, serde_json::to_string(&config).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_entry_uses_opencode_schema_without_vault_path() {
|
||||
let entry = build_entry("node", "/app/mcp-server/index.js");
|
||||
|
||||
assert_eq!(
|
||||
entry,
|
||||
serde_json::json!({
|
||||
"type": "local",
|
||||
"command": ["node", "/app/mcp-server/index.js"],
|
||||
"enabled": true,
|
||||
"environment": {
|
||||
"WS_UI_PORT": "9711"
|
||||
}
|
||||
})
|
||||
);
|
||||
assert!(entry["environment"]["VAULT_PATH"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_config_preserves_other_opencode_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("opencode.json");
|
||||
write_config_json(
|
||||
&config_path,
|
||||
serde_json::json!({
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"other": { "type": "local" }
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let was_update = upsert_config(&config_path, &build_entry("node", "/index.js")).unwrap();
|
||||
let config = read_config(&config_path);
|
||||
|
||||
assert!(!was_update);
|
||||
assert_eq!(config["$schema"], "https://opencode.ai/config.json");
|
||||
assert!(config["mcp"]["other"].is_object());
|
||||
assert_eq!(config["mcp"][MCP_SERVER_NAME]["command"][1], "/index.js");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_config_migrates_legacy_server_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("opencode.json");
|
||||
write_config_json(
|
||||
&config_path,
|
||||
serde_json::json!({
|
||||
"mcp": {
|
||||
"laputa": { "type": "local", "command": ["node", "/old.js"] }
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let was_update = upsert_config(&config_path, &build_entry("node", "/new.js")).unwrap();
|
||||
let config = read_config(&config_path);
|
||||
|
||||
assert!(was_update);
|
||||
assert!(config["mcp"][LEGACY_MCP_SERVER_NAME].is_null());
|
||||
assert_eq!(config["mcp"][MCP_SERVER_NAME]["command"][1], "/new.js");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_config_removes_primary_and_legacy_entries() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("opencode.json");
|
||||
write_config_json(
|
||||
&config_path,
|
||||
serde_json::json!({
|
||||
"mcp": {
|
||||
"tolaria": { "type": "local" },
|
||||
"laputa": { "type": "local" },
|
||||
"other": { "type": "local" }
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(remove_config(&config_path).unwrap());
|
||||
let config = read_config(&config_path);
|
||||
assert!(config["mcp"][MCP_SERVER_NAME].is_null());
|
||||
assert!(config["mcp"][LEGACY_MCP_SERVER_NAME].is_null());
|
||||
assert!(config["mcp"]["other"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_is_installed_checks_opencode_shape_and_index_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let index_js = tmp.path().join("index.js");
|
||||
std::fs::write(&index_js, "").unwrap();
|
||||
|
||||
let entry = build_entry("node", &index_js.to_string_lossy());
|
||||
assert!(entry_is_installed(&entry));
|
||||
|
||||
let missing = build_entry("node", &tmp.path().join("missing.js").to_string_lossy());
|
||||
assert!(!entry_is_installed(&missing));
|
||||
}
|
||||
}
|
||||
@@ -86,14 +86,25 @@ fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf>
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
first_existing_path_for_platform(stdout, cfg!(windows))
|
||||
}
|
||||
|
||||
fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
|
||||
let mut paths = stdout.lines().filter_map(existing_path);
|
||||
if windows {
|
||||
return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path));
|
||||
}
|
||||
|
||||
paths.next()
|
||||
}
|
||||
|
||||
fn existing_path(line: &str) -> Option<PathBuf> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
}
|
||||
|
||||
fn opencode_binary_candidates() -> Vec<PathBuf> {
|
||||
@@ -105,12 +116,16 @@ fn opencode_binary_candidates() -> Vec<PathBuf> {
|
||||
fn opencode_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".local/bin/opencode"),
|
||||
home.join(".local/bin/opencode.cmd"),
|
||||
home.join(".local/bin/opencode.exe"),
|
||||
home.join(".opencode/bin/opencode"),
|
||||
home.join(".opencode/bin/opencode.cmd"),
|
||||
home.join(".opencode/bin/opencode.exe"),
|
||||
home.join(".local/share/mise/shims/opencode"),
|
||||
home.join(".local/share/mise/shims/opencode.cmd"),
|
||||
home.join(".local/share/mise/shims/opencode.exe"),
|
||||
home.join(".asdf/shims/opencode"),
|
||||
home.join(".asdf/shims/opencode.cmd"),
|
||||
home.join(".asdf/shims/opencode.exe"),
|
||||
home.join(".npm-global/bin/opencode"),
|
||||
home.join(".npm-global/bin/opencode.cmd"),
|
||||
@@ -119,12 +134,14 @@ fn opencode_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
home.join(".npm/bin/opencode.cmd"),
|
||||
home.join(".npm/bin/opencode.exe"),
|
||||
home.join(".bun/bin/opencode"),
|
||||
home.join(".bun/bin/opencode.cmd"),
|
||||
home.join(".bun/bin/opencode.exe"),
|
||||
home.join(".linuxbrew/bin/opencode"),
|
||||
home.join("AppData/Roaming/npm/opencode.cmd"),
|
||||
home.join("AppData/Roaming/npm/opencode.exe"),
|
||||
home.join("AppData/Local/pnpm/opencode.cmd"),
|
||||
home.join("AppData/Local/pnpm/opencode.exe"),
|
||||
home.join("scoop/shims/opencode.cmd"),
|
||||
home.join("scoop/shims/opencode.exe"),
|
||||
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/opencode"),
|
||||
PathBuf::from("/usr/local/bin/opencode"),
|
||||
@@ -182,14 +199,20 @@ mod tests {
|
||||
let home = PathBuf::from(r"C:\Users\alex");
|
||||
let candidates = opencode_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/opencode.cmd"),
|
||||
home.join(".opencode/bin/opencode.cmd"),
|
||||
home.join(".local/share/mise/shims/opencode.cmd"),
|
||||
home.join(".asdf/shims/opencode.cmd"),
|
||||
home.join(".npm-global/bin/opencode.cmd"),
|
||||
home.join(".npm-global/bin/opencode.exe"),
|
||||
home.join(".npm/bin/opencode.cmd"),
|
||||
home.join(".npm/bin/opencode.exe"),
|
||||
home.join(".bun/bin/opencode.cmd"),
|
||||
home.join("AppData/Roaming/npm/opencode.cmd"),
|
||||
home.join("AppData/Roaming/npm/opencode.exe"),
|
||||
home.join("AppData/Local/pnpm/opencode.cmd"),
|
||||
home.join("AppData/Local/pnpm/opencode.exe"),
|
||||
home.join("scoop/shims/opencode.cmd"),
|
||||
home.join("scoop/shims/opencode.exe"),
|
||||
];
|
||||
|
||||
@@ -220,4 +243,20 @@ mod tests {
|
||||
|
||||
assert_eq!(first_existing_path(&stdout), Some(opencode));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let shell_script = dir.path().join("opencode");
|
||||
let cmd_shim = dir.path().join("opencode.cmd");
|
||||
std::fs::write(&shell_script, "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(&cmd_shim, "@ECHO off\n").unwrap();
|
||||
|
||||
let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display());
|
||||
|
||||
assert_eq!(
|
||||
first_existing_path_for_platform(&stdout, true),
|
||||
Some(cmd_shim)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,12 +69,14 @@ fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
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))
|
||||
["-lc", "-lic"].into_iter().find_map(|flags| {
|
||||
crate::hidden_command(shell)
|
||||
.arg(flags)
|
||||
.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> {
|
||||
@@ -118,12 +120,21 @@ fn pi_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
home.join(".local/share/mise/shims/pi.exe"),
|
||||
home.join(".asdf/shims/pi"),
|
||||
home.join(".asdf/shims/pi.exe"),
|
||||
home.join(".volta/bin/pi"),
|
||||
home.join(".volta/bin/pi.cmd"),
|
||||
home.join(".volta/bin/pi.exe"),
|
||||
home.join(".npm-global/bin/pi"),
|
||||
home.join(".npm-global/bin/pi.cmd"),
|
||||
home.join(".npm-global/bin/pi.exe"),
|
||||
home.join(".npm/bin/pi"),
|
||||
home.join(".npm/bin/pi.cmd"),
|
||||
home.join(".npm/bin/pi.exe"),
|
||||
home.join(".local/share/pnpm/pi"),
|
||||
home.join(".local/share/pnpm/pi.cmd"),
|
||||
home.join(".local/share/pnpm/pi.exe"),
|
||||
home.join("Library/pnpm/pi"),
|
||||
home.join("Library/pnpm/pi.cmd"),
|
||||
home.join("Library/pnpm/pi.exe"),
|
||||
home.join(".bun/bin/pi"),
|
||||
home.join(".bun/bin/pi.exe"),
|
||||
home.join(".linuxbrew/bin/pi"),
|
||||
@@ -148,14 +159,21 @@ fn pi_binary_candidates_from_env() -> Vec<PathBuf> {
|
||||
let nvm_bin = std::env::var_os("NVM_BIN").map(PathBuf::from);
|
||||
let npm_config_prefix = std::env::var_os("npm_config_prefix").map(PathBuf::from);
|
||||
let npm_config_prefix_upper = std::env::var_os("NPM_CONFIG_PREFIX").map(PathBuf::from);
|
||||
let pnpm_home = std::env::var_os("PNPM_HOME").map(PathBuf::from);
|
||||
|
||||
pi_binary_candidates_from_prefixes(nvm_bin, npm_config_prefix, npm_config_prefix_upper)
|
||||
pi_binary_candidates_from_prefixes(
|
||||
nvm_bin,
|
||||
npm_config_prefix,
|
||||
npm_config_prefix_upper,
|
||||
pnpm_home,
|
||||
)
|
||||
}
|
||||
|
||||
fn pi_binary_candidates_from_prefixes(
|
||||
nvm_bin: Option<PathBuf>,
|
||||
npm_config_prefix: Option<PathBuf>,
|
||||
npm_config_prefix_upper: Option<PathBuf>,
|
||||
pnpm_home: Option<PathBuf>,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
@@ -168,6 +186,9 @@ fn pi_binary_candidates_from_prefixes(
|
||||
if let Some(path) = npm_config_prefix_upper.filter(|path| !path.as_os_str().is_empty()) {
|
||||
candidates.push(path.join("bin/pi"));
|
||||
}
|
||||
if let Some(path) = pnpm_home.filter(|path| !path.as_os_str().is_empty()) {
|
||||
candidates.push(path.join("pi"));
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
@@ -203,7 +224,10 @@ mod tests {
|
||||
home.join(".pi/bin/pi"),
|
||||
home.join(".local/share/mise/shims/pi"),
|
||||
home.join(".asdf/shims/pi"),
|
||||
home.join(".volta/bin/pi"),
|
||||
home.join(".npm-global/bin/pi"),
|
||||
home.join(".local/share/pnpm/pi"),
|
||||
home.join("Library/pnpm/pi"),
|
||||
home.join(".bun/bin/pi"),
|
||||
];
|
||||
|
||||
@@ -245,6 +269,10 @@ mod tests {
|
||||
home.join(".npm-global/bin/pi.exe"),
|
||||
home.join(".npm/bin/pi.cmd"),
|
||||
home.join(".npm/bin/pi.exe"),
|
||||
home.join(".local/share/pnpm/pi.cmd"),
|
||||
home.join(".local/share/pnpm/pi.exe"),
|
||||
home.join("Library/pnpm/pi.cmd"),
|
||||
home.join("Library/pnpm/pi.exe"),
|
||||
home.join("AppData/Roaming/npm/pi.cmd"),
|
||||
home.join("AppData/Roaming/npm/pi.exe"),
|
||||
home.join("AppData/Local/pnpm/pi.cmd"),
|
||||
@@ -298,11 +326,13 @@ mod tests {
|
||||
let nvm_bin = PathBuf::from("/Users/alex/.nvm/versions/node/v22.20.0/bin");
|
||||
let npm_config_prefix = PathBuf::from("/Users/alex/.npm-global");
|
||||
let npm_config_prefix_upper = PathBuf::from("/Users/alex/.npm");
|
||||
let pnpm_home = PathBuf::from("/Users/alex/Library/pnpm");
|
||||
|
||||
let candidates = pi_binary_candidates_from_prefixes(
|
||||
Some(nvm_bin.clone()),
|
||||
Some(npm_config_prefix.clone()),
|
||||
Some(npm_config_prefix_upper.clone()),
|
||||
Some(pnpm_home.clone()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -311,6 +341,7 @@ mod tests {
|
||||
nvm_bin.join("pi"),
|
||||
npm_config_prefix.join("bin/pi"),
|
||||
npm_config_prefix_upper.join("bin/pi"),
|
||||
pnpm_home.join("pi"),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -326,4 +357,28 @@ mod tests {
|
||||
|
||||
assert_eq!(first_existing_path(&stdout), Some(pi));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_path_from_shell_finds_pi_from_interactive_login_shell() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pi = dir.path().join("pi");
|
||||
std::fs::write(&pi, "#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&pi, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let shell = dir.path().join("shell");
|
||||
std::fs::write(
|
||||
&shell,
|
||||
format!(
|
||||
"#!/bin/sh\nif [ \"$1\" = \"-lic\" ]; then echo '{}'; fi\n",
|
||||
pi.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
assert_eq!(command_path_from_shell(&shell, "pi"), Some(pi));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ use crate::ai_models::{normalize_ai_model_providers, AiModelProvider};
|
||||
|
||||
const APP_CONFIG_DIR: &str = "com.tolaria.app";
|
||||
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
|
||||
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode", "pi", "gemini"];
|
||||
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] =
|
||||
&["claude_code", "codex", "opencode", "pi", "gemini", "kiro"];
|
||||
pub const DEFAULT_HIDE_GITIGNORED_FILES: bool = true;
|
||||
const SUPPORTED_NOTE_WIDTH_MODES: &[&str] = &["normal", "wide"];
|
||||
const SUPPORTED_DATE_DISPLAY_FORMATS: &[&str] = &["us", "european", "friendly", "iso"];
|
||||
@@ -67,6 +68,7 @@ const SUPPORTED_UI_LANGUAGE_ALIASES: &[(&str, &str)] = &[
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Settings {
|
||||
pub auto_pull_interval_minutes: Option<u32>,
|
||||
pub git_enabled: Option<bool>,
|
||||
pub autogit_enabled: Option<bool>,
|
||||
pub autogit_idle_threshold_seconds: Option<u32>,
|
||||
pub autogit_inactive_threshold_seconds: Option<u32>,
|
||||
@@ -177,6 +179,7 @@ pub fn normalize_ui_language(value: Option<&str>) -> Option<String> {
|
||||
fn normalize_settings(settings: Settings) -> Settings {
|
||||
Settings {
|
||||
auto_pull_interval_minutes: settings.auto_pull_interval_minutes,
|
||||
git_enabled: settings.git_enabled,
|
||||
autogit_enabled: settings.autogit_enabled,
|
||||
autogit_idle_threshold_seconds: normalize_optional_positive_u32(
|
||||
settings.autogit_idle_threshold_seconds,
|
||||
@@ -334,6 +337,7 @@ mod tests {
|
||||
fn test_settings_json_roundtrip() {
|
||||
let settings = Settings {
|
||||
auto_pull_interval_minutes: Some(10),
|
||||
git_enabled: Some(false),
|
||||
autogit_enabled: Some(true),
|
||||
autogit_idle_threshold_seconds: Some(90),
|
||||
autogit_inactive_threshold_seconds: Some(30),
|
||||
@@ -376,6 +380,7 @@ mod tests {
|
||||
fn test_save_and_load_preserves_values() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
auto_pull_interval_minutes: Some(10),
|
||||
git_enabled: Some(false),
|
||||
autogit_enabled: Some(true),
|
||||
autogit_idle_threshold_seconds: Some(90),
|
||||
autogit_inactive_threshold_seconds: Some(30),
|
||||
@@ -397,6 +402,7 @@ mod tests {
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(loaded.auto_pull_interval_minutes, Some(10));
|
||||
assert_eq!(loaded.git_enabled, Some(false));
|
||||
assert_eq!(loaded.autogit_enabled, Some(true));
|
||||
assert_eq!(loaded.autogit_idle_threshold_seconds, Some(90));
|
||||
assert_eq!(loaded.autogit_inactive_threshold_seconds, Some(30));
|
||||
|
||||
@@ -19,8 +19,8 @@ use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry
|
||||
|
||||
/// Bump this when VaultEntry fields change to force a full rescan.
|
||||
/// v12: fix gray_matter YAML sanitization (unquoted colons / hash comments in list items)
|
||||
/// v13: preserve plain square brackets in parsed markdown H1 titles
|
||||
const CACHE_VERSION: u32 = 13;
|
||||
/// v14: preserve scalar-array custom frontmatter properties in VaultEntry
|
||||
const CACHE_VERSION: u32 = 14;
|
||||
const CACHE_WRITE_LOCK_STALE_SECS: u64 = 30;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -73,8 +73,8 @@ pub struct VaultEntry {
|
||||
/// Extracted from `[[target]]` and `[[target|display]]` patterns.
|
||||
#[serde(rename = "outgoingLinks", default)]
|
||||
pub outgoing_links: Vec<String>,
|
||||
/// Custom scalar frontmatter properties (non-relationship, non-structural).
|
||||
/// Only includes strings, numbers, and booleans — arrays/objects are excluded.
|
||||
/// Custom scalar and scalar-array frontmatter properties (non-relationship, non-structural).
|
||||
/// Objects and arrays containing wikilinks are excluded.
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, serde_json::Value>,
|
||||
/// Properties to display as chips in the note list for this Type's notes.
|
||||
|
||||
@@ -289,7 +289,26 @@ fn nested_flow_wikilink(arr: &[serde_json::Value], depth: usize) -> Option<Strin
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract custom scalar properties from raw YAML frontmatter.
|
||||
fn scalar_array_property_value(arr: &[serde_json::Value]) -> Option<serde_json::Value> {
|
||||
let mut values = Vec::new();
|
||||
for item in arr {
|
||||
let sanitized = sanitize_array_item(item)?;
|
||||
match sanitized {
|
||||
serde_json::Value::String(ref s) if contains_wikilink(s) => return None,
|
||||
serde_json::Value::String(_)
|
||||
| serde_json::Value::Number(_)
|
||||
| serde_json::Value::Bool(_) => values.push(sanitized),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
|
||||
match values.as_slice() {
|
||||
[single] => Some(single.clone()),
|
||||
_ => Some(serde_json::Value::Array(values)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract custom scalar and scalar-array properties from raw YAML frontmatter.
|
||||
pub(crate) fn extract_properties(
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
@@ -310,13 +329,9 @@ pub(crate) fn extract_properties(
|
||||
serde_json::Value::Number(_) | serde_json::Value::Bool(_) => {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
// Handle single-element arrays: unwrap to scalar.
|
||||
// This ensures YAML like "Owner: [Luca]" or "Owner:\n - Luca" works correctly.
|
||||
serde_json::Value::Array(arr) => {
|
||||
if let [serde_json::Value::String(s)] = arr.as_slice() {
|
||||
if !contains_wikilink(s) {
|
||||
properties.insert(key.clone(), serde_json::Value::String(s.clone()));
|
||||
}
|
||||
if let Some(value) = scalar_array_property_value(arr) {
|
||||
properties.insert(key.clone(), value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -78,16 +78,10 @@ fn assert_alias_parser_recovers(case: AliasRecoveryCase<'_>) {
|
||||
|
||||
#[test]
|
||||
fn test_filtered_properties_stay_hidden() {
|
||||
let cases = [
|
||||
HiddenPropertyCase {
|
||||
content: "---\nMentor: \"[[person/alice]]\"\nCompany: Acme Corp\n---\n# Test\n",
|
||||
hidden_key: "Mentor",
|
||||
},
|
||||
HiddenPropertyCase {
|
||||
content: "---\nTags:\n - productivity\n - writing\nCompany: Acme Corp\n---\n# Test\n",
|
||||
hidden_key: "Tags",
|
||||
},
|
||||
];
|
||||
let cases = [HiddenPropertyCase {
|
||||
content: "---\nMentor: \"[[person/alice]]\"\nCompany: Acme Corp\n---\n# Test\n",
|
||||
hidden_key: "Mentor",
|
||||
}];
|
||||
|
||||
for case in cases {
|
||||
assert_filtered_property_stays_hidden(case);
|
||||
@@ -114,6 +108,21 @@ fn test_single_element_array_properties_unwrap_to_scalars() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_element_array_properties_are_preserved() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"playlist.md",
|
||||
"---\ntags:\n - blues\n - chicago\n---\n# Playlist\n",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
entry.properties.get("tags"),
|
||||
Some(&serde_json::json!(["blues", "chicago"]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blank_scalar_properties_are_preserved() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -414,6 +414,7 @@ Use kebab-case: `my-note-title.md`. One note per file.
|
||||
- Create and edit saved views in `views/`.
|
||||
- Update `AGENTS.md` only when the user asks for vault-level guidance changes.
|
||||
- Search the bundled Tolaria docs when the user asks how Tolaria works or when you need product behavior beyond these base conventions.
|
||||
- Use Portent as the default best-practice model when the user asks how to improve, organize, or restructure the knowledge base. Combine Portent's types, relationships, and capture -> organize -> archive lifecycle with Tolaria's type documents, properties, Inbox, archive, and saved views.
|
||||
|
||||
## What agents should avoid
|
||||
|
||||
|
||||
@@ -405,6 +405,34 @@ filters:
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_matches_scalar_array_property_element() {
|
||||
let yaml = r#"
|
||||
name: Tagged
|
||||
filters:
|
||||
all:
|
||||
- field: tags
|
||||
op: contains
|
||||
value: blues
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let matching = make_entry(|e| {
|
||||
e.properties
|
||||
.insert("tags".to_string(), serde_json::json!(["blues", "chicago"]));
|
||||
});
|
||||
let partial = make_entry(|e| {
|
||||
e.properties.insert(
|
||||
"tags".to_string(),
|
||||
serde_json::json!(["bluegrass", "chicago"]),
|
||||
);
|
||||
});
|
||||
|
||||
let entries = vec![matching, partial];
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_body_contains_filters_on_snippet() {
|
||||
let yaml = r#"
|
||||
|
||||
@@ -7,6 +7,12 @@ pub(super) fn json_scalar_to_string(value: &serde_json::Value) -> Option<String>
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn json_scalar_array_to_strings(value: &serde_json::Value) -> Option<Vec<String>> {
|
||||
value
|
||||
.as_array()
|
||||
.map(|sequence| sequence.iter().filter_map(json_scalar_to_string).collect())
|
||||
}
|
||||
|
||||
pub(super) fn yaml_value_to_string(value: &serde_yaml::Value) -> Option<String> {
|
||||
match value {
|
||||
serde_yaml::Value::String(value) => Some(value.clone()),
|
||||
|
||||
@@ -12,7 +12,8 @@ use super::view_date_filters::parse_date_filter_timestamp;
|
||||
use super::view_migration::{is_view_definition_file, migrate_views};
|
||||
use super::view_relationships::{evaluate_relationship_op, relationship_candidates};
|
||||
use super::view_value_conversions::{
|
||||
json_scalar_to_string, yaml_value_to_string, yaml_value_to_string_vec,
|
||||
json_scalar_array_to_strings, json_scalar_to_string, yaml_value_to_string,
|
||||
yaml_value_to_string_vec,
|
||||
};
|
||||
use super::VaultEntry;
|
||||
|
||||
@@ -292,6 +293,7 @@ fn supports_regex(op: &FilterOp) -> bool {
|
||||
|
||||
enum ConditionField<'a> {
|
||||
Scalar(Option<String>),
|
||||
PropertyArray(Vec<String>),
|
||||
Relationship(&'a [String]),
|
||||
}
|
||||
|
||||
@@ -314,6 +316,9 @@ fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
|
||||
}
|
||||
|
||||
match field_value {
|
||||
ConditionField::PropertyArray(values) => {
|
||||
evaluate_property_array_op(&cond.op, &values, &cond.value)
|
||||
}
|
||||
ConditionField::Relationship(rels) => evaluate_relationship_op(&cond.op, rels, &cond.value),
|
||||
ConditionField::Scalar(value) => evaluate_scalar_op(
|
||||
&cond.op,
|
||||
@@ -349,6 +354,9 @@ fn resolve_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionF
|
||||
|
||||
fn resolve_dynamic_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> {
|
||||
if let Some(prop) = entry.properties.get(field) {
|
||||
if let Some(values) = json_scalar_array_to_strings(prop) {
|
||||
return ConditionField::PropertyArray(values);
|
||||
}
|
||||
return ConditionField::Scalar(json_scalar_to_string(prop));
|
||||
}
|
||||
if let Some(relationships) = entry.relationships.get(field) {
|
||||
@@ -380,6 +388,7 @@ fn invalid_regex_requested(cond: &FilterCondition, regex: Option<&Regex>) -> boo
|
||||
fn evaluate_regex_condition(op: &FilterOp, field: &ConditionField<'_>, regex: &Regex) -> bool {
|
||||
let matched = match field {
|
||||
ConditionField::Scalar(Some(value)) => regex.is_match(value),
|
||||
ConditionField::PropertyArray(values) => values.iter().any(|value| regex.is_match(value)),
|
||||
ConditionField::Relationship(values) => values.iter().any(|item| {
|
||||
relationship_candidates(item)
|
||||
.into_iter()
|
||||
@@ -395,6 +404,48 @@ fn evaluate_regex_condition(op: &FilterOp, field: &ConditionField<'_>, regex: &R
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_property_array_op(
|
||||
op: &FilterOp,
|
||||
values: &[String],
|
||||
raw_value: &Option<serde_yaml::Value>,
|
||||
) -> bool {
|
||||
match op {
|
||||
FilterOp::Contains => property_array_matches_value(values, raw_value),
|
||||
FilterOp::NotContains => !property_array_matches_value(values, raw_value),
|
||||
FilterOp::AnyOf => property_array_matches_any(values, raw_value),
|
||||
FilterOp::NoneOf => !property_array_matches_any(values, raw_value),
|
||||
FilterOp::Equals => values.len() == 1 && property_array_matches_value(values, raw_value),
|
||||
FilterOp::NotEquals => {
|
||||
!(values.len() == 1 && property_array_matches_value(values, raw_value))
|
||||
}
|
||||
FilterOp::IsEmpty => values.is_empty(),
|
||||
FilterOp::IsNotEmpty => !values.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn property_array_matches_value(values: &[String], raw_value: &Option<serde_yaml::Value>) -> bool {
|
||||
raw_value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string)
|
||||
.is_some_and(|target| property_array_contains(values, &target))
|
||||
}
|
||||
|
||||
fn property_array_matches_any(values: &[String], raw_value: &Option<serde_yaml::Value>) -> bool {
|
||||
raw_value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|target| property_array_contains(values, target))
|
||||
}
|
||||
|
||||
fn property_array_contains(values: &[String], target: &str) -> bool {
|
||||
values
|
||||
.iter()
|
||||
.any(|value| value.eq_ignore_ascii_case(target))
|
||||
}
|
||||
|
||||
fn evaluate_scalar_op(
|
||||
op: &FilterOp,
|
||||
field_value: Option<&str>,
|
||||
|
||||
@@ -23,19 +23,61 @@ 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")
|
||||
is_exact_temp_file_name(name) || has_temp_file_prefix(name) || has_temp_file_suffix(name)
|
||||
}
|
||||
|
||||
fn is_watchable_path(path: &Path) -> bool {
|
||||
fn is_exact_temp_file_name(name: &str) -> bool {
|
||||
[".DS_Store", ".tolaria-rename-txn"].contains(&name)
|
||||
}
|
||||
|
||||
fn has_temp_file_prefix(name: &str) -> bool {
|
||||
[".#", ".gitstatus."]
|
||||
.iter()
|
||||
.any(|prefix| name.starts_with(prefix))
|
||||
}
|
||||
|
||||
fn has_temp_file_suffix(name: &str) -> bool {
|
||||
["~", ".tmp", ".swp", ".swx", ".icloud"]
|
||||
.iter()
|
||||
.any(|suffix| name.ends_with(suffix))
|
||||
}
|
||||
|
||||
/// Resolve the real git directory for `vault_path`. Handles three cases:
|
||||
/// - regular `.git/` directory
|
||||
/// - `.git` symlink (e.g. the iCloud `.git -> .git.nosync` workaround)
|
||||
/// - `.git` file containing `gitdir: <path>` (worktrees, submodules)
|
||||
fn resolve_git_dir(vault_path: &Path) -> Option<PathBuf> {
|
||||
let git_path = vault_path.join(".git");
|
||||
if let Ok(target) = std::fs::read_link(&git_path) {
|
||||
let resolved = if target.is_absolute() {
|
||||
target
|
||||
} else {
|
||||
vault_path.join(target)
|
||||
};
|
||||
return Some(resolved);
|
||||
}
|
||||
if git_path.is_dir() {
|
||||
return Some(git_path);
|
||||
}
|
||||
let content = std::fs::read_to_string(&git_path).ok()?;
|
||||
let rest = content.lines().next()?.strip_prefix("gitdir:")?.trim();
|
||||
let target = Path::new(rest);
|
||||
Some(if target.is_absolute() {
|
||||
target.to_path_buf()
|
||||
} else {
|
||||
vault_path.join(target)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_watchable_path(path: &Path, git_dir: Option<&Path>) -> bool {
|
||||
if has_ignored_component(path) {
|
||||
return false;
|
||||
}
|
||||
if let Some(git_dir) = git_dir {
|
||||
if path.starts_with(git_dir) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
match path.file_name() {
|
||||
Some(name) => !is_temp_file_name(name),
|
||||
None => true,
|
||||
@@ -51,7 +93,9 @@ mod desktop {
|
||||
};
|
||||
use tauri::Emitter;
|
||||
|
||||
use super::{is_watchable_path, Path, PathBuf, VaultChangedPayload, VAULT_CHANGED_EVENT};
|
||||
use super::{
|
||||
is_watchable_path, resolve_git_dir, Path, PathBuf, VaultChangedPayload, VAULT_CHANGED_EVENT,
|
||||
};
|
||||
|
||||
struct ActiveVaultWatcher {
|
||||
path: PathBuf,
|
||||
@@ -93,20 +137,25 @@ mod desktop {
|
||||
!matches!(event.kind, EventKind::Access(_))
|
||||
}
|
||||
|
||||
fn changed_paths(event: Event) -> Vec<String> {
|
||||
fn changed_paths(event: Event, git_dir: Option<&Path>) -> Vec<String> {
|
||||
if !should_emit_event(&event) {
|
||||
return Vec::new();
|
||||
}
|
||||
event
|
||||
.paths
|
||||
.into_iter()
|
||||
.filter(|path| is_watchable_path(path))
|
||||
.filter(|path| is_watchable_path(path, git_dir))
|
||||
.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);
|
||||
fn emit_vault_change(
|
||||
app: &tauri::AppHandle,
|
||||
vault_path: &Path,
|
||||
git_dir: Option<&Path>,
|
||||
event: Event,
|
||||
) {
|
||||
let paths = changed_paths(event, git_dir);
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -134,9 +183,15 @@ mod desktop {
|
||||
}
|
||||
|
||||
let event_vault_path = vault_path.clone();
|
||||
let event_git_dir = resolve_git_dir(&vault_path);
|
||||
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),
|
||||
Ok(event) => emit_vault_change(
|
||||
&event_app,
|
||||
&event_vault_path,
|
||||
event_git_dir.as_deref(),
|
||||
event,
|
||||
),
|
||||
Err(err) => log::warn!("Vault watcher event failed: {}", err),
|
||||
})
|
||||
.map_err(|err| format!("Failed to create vault watcher: {err}"))?;
|
||||
@@ -194,43 +249,49 @@ mod desktop {
|
||||
|
||||
#[test]
|
||||
fn changed_paths_ignores_access_events() {
|
||||
let paths = changed_paths(event(
|
||||
EventKind::Access(AccessKind::Read),
|
||||
&["notes/today.md"],
|
||||
));
|
||||
let paths = changed_paths(
|
||||
event(EventKind::Access(AccessKind::Read), &["notes/today.md"]),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(paths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_paths_filters_unwatchable_paths() {
|
||||
let paths = changed_paths(event(
|
||||
EventKind::Create(CreateKind::File),
|
||||
&[
|
||||
".git/index.lock",
|
||||
"node_modules/pkg/index.js",
|
||||
"notes/today.md",
|
||||
],
|
||||
));
|
||||
let paths = changed_paths(
|
||||
event(
|
||||
EventKind::Create(CreateKind::File),
|
||||
&[
|
||||
".git/index.lock",
|
||||
"node_modules/pkg/index.js",
|
||||
"notes/today.md",
|
||||
],
|
||||
),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(paths, vec!["notes/today.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_paths_filters_editor_temporary_files() {
|
||||
let paths = changed_paths(event(
|
||||
EventKind::Create(CreateKind::File),
|
||||
&[
|
||||
".DS_Store",
|
||||
".tolaria-rename-txn",
|
||||
".#draft.md",
|
||||
"draft.md~",
|
||||
"draft.tmp",
|
||||
"draft.swp",
|
||||
"draft.swx",
|
||||
"notes/keep.md",
|
||||
],
|
||||
));
|
||||
let paths = changed_paths(
|
||||
event(
|
||||
EventKind::Create(CreateKind::File),
|
||||
&[
|
||||
".DS_Store",
|
||||
".tolaria-rename-txn",
|
||||
".#draft.md",
|
||||
"draft.md~",
|
||||
"draft.tmp",
|
||||
"draft.swp",
|
||||
"draft.swx",
|
||||
"notes/keep.md",
|
||||
],
|
||||
),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(paths, vec!["notes/keep.md"]);
|
||||
}
|
||||
@@ -299,30 +360,86 @@ pub fn stop_vault_watcher() -> Result<(), String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_watchable_path;
|
||||
use std::path::Path;
|
||||
use super::{is_watchable_path, resolve_git_dir};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[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"
|
||||
)));
|
||||
assert!(!is_watchable_path(Path::new(".git/index.lock"), None));
|
||||
assert!(!is_watchable_path(
|
||||
Path::new("node_modules/package/index.js"),
|
||||
None
|
||||
));
|
||||
}
|
||||
|
||||
#[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")));
|
||||
assert!(!is_watchable_path(Path::new("note.md.tmp"), None));
|
||||
assert!(!is_watchable_path(Path::new("note.md.swp"), None));
|
||||
assert!(!is_watchable_path(Path::new("draft.md~"), None));
|
||||
assert!(!is_watchable_path(Path::new(".DS_Store"), None));
|
||||
assert!(!is_watchable_path(Path::new(".tolaria-rename-txn"), None));
|
||||
assert!(!is_watchable_path(Path::new(".gitstatus.KASSUJ"), None));
|
||||
assert!(!is_watchable_path(Path::new("notes/draft.md.icloud"), None));
|
||||
}
|
||||
|
||||
#[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")));
|
||||
assert!(is_watchable_path(Path::new("notes/day.md"), None));
|
||||
assert!(is_watchable_path(Path::new("attachments/image.png"), None));
|
||||
assert!(is_watchable_path(Path::new(".laputa/views/work.yml"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_paths_inside_resolved_git_dir() {
|
||||
// .git -> .git.nosync symlink trick used for iCloud/Dropbox vaults
|
||||
let git_dir = PathBuf::from("/vault/.git.nosync");
|
||||
assert!(!is_watchable_path(
|
||||
Path::new("/vault/.git.nosync/index.lock"),
|
||||
Some(&git_dir)
|
||||
));
|
||||
assert!(!is_watchable_path(
|
||||
Path::new("/vault/.git.nosync/refs/remotes/origin/HEAD"),
|
||||
Some(&git_dir)
|
||||
));
|
||||
assert!(is_watchable_path(
|
||||
Path::new("/vault/notes/day.md"),
|
||||
Some(&git_dir)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_real_git_dir_through_symlink() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real_git = dir.path().join(".git.nosync");
|
||||
std::fs::create_dir(&real_git).unwrap();
|
||||
std::os::unix::fs::symlink(".git.nosync", dir.path().join(".git")).unwrap();
|
||||
|
||||
let resolved = resolve_git_dir(dir.path()).unwrap();
|
||||
assert_eq!(resolved, dir.path().join(".git.nosync"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_real_git_dir_for_regular_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join(".git")).unwrap();
|
||||
|
||||
let resolved = resolve_git_dir(dir.path()).unwrap();
|
||||
assert_eq!(resolved, dir.path().join(".git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_real_git_dir_for_worktree_pointer_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let worktree_target = dir.path().join("main/.git/worktrees/foo");
|
||||
std::fs::create_dir_all(&worktree_target).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join(".git"),
|
||||
format!("gitdir: {}\n", worktree_target.display()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_git_dir(dir.path()).unwrap();
|
||||
assert_eq!(resolved, worktree_target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,13 @@ const relatedEntry = makeEntry({
|
||||
title: 'Software Development',
|
||||
isA: 'Topic',
|
||||
})
|
||||
const entries = [activeEntry, relatedEntry]
|
||||
const secondEntry = makeEntry({
|
||||
path: '/vault/project/second.md',
|
||||
filename: 'second.md',
|
||||
title: 'Second Project',
|
||||
isA: 'Project',
|
||||
})
|
||||
const entries = [activeEntry, relatedEntry, secondEntry]
|
||||
const noteContent = '---\ntitle: Test Project\ntype: Project\n---\n\n# Test Project\n'
|
||||
const defaultVaultPath = DEFAULT_VAULTS[0].path || '/Users/mock/Documents/Getting Started'
|
||||
|
||||
@@ -215,7 +221,7 @@ describe('App note windows', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('passes the loaded vault index to the editor in note windows', async () => {
|
||||
it('keeps note windows scoped to the active entry without loading the vault index', async () => {
|
||||
renderApp(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -224,18 +230,44 @@ describe('App note windows', () => {
|
||||
vaultPath: '/vault',
|
||||
})
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(commandResults.list_vault).toHaveBeenCalledWith({ path: '/vault' })
|
||||
})
|
||||
expect(commandResults.list_vault).not.toHaveBeenCalled()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mock-editor-entry-titles')).toHaveTextContent(
|
||||
'Test Project|Software Development',
|
||||
'Test Project',
|
||||
)
|
||||
})
|
||||
expect(editorSnapshots.at(-1)).toEqual({
|
||||
activeTabPath: activeEntry.path,
|
||||
entryTitles: ['Test Project', 'Software Development'],
|
||||
entryTitles: ['Test Project'],
|
||||
})
|
||||
})
|
||||
|
||||
it('opens repeated note windows without repeated full-vault scans', async () => {
|
||||
const firstWindow = renderApp(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(commandResults.reload_vault_entry).toHaveBeenCalledWith({
|
||||
path: activeEntry.path,
|
||||
vaultPath: '/vault',
|
||||
})
|
||||
})
|
||||
firstWindow.unmount()
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
'/?window=note&path=%2Fvault%2Fproject%2Fsecond.md&vault=%2Fvault&title=Second+Project',
|
||||
)
|
||||
renderApp(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(commandResults.reload_vault_entry).toHaveBeenCalledWith({
|
||||
path: secondEntry.path,
|
||||
vaultPath: '/vault',
|
||||
})
|
||||
})
|
||||
expect(commandResults.reload_vault_entry).toHaveBeenCalledTimes(2)
|
||||
expect(commandResults.list_vault).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -365,24 +365,23 @@ vi.mock('./hooks/useUpdater', async () => {
|
||||
|
||||
// Mock BlockNote components (they need DOM APIs not available in jsdom)
|
||||
vi.mock('@blocknote/core', () => ({
|
||||
audioParse: vi.fn(() => undefined), createAudioBlockConfig: vi.fn(() => ({})),
|
||||
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
|
||||
createCodeBlockSpec: vi.fn(() => ({})),
|
||||
createExtension: (factory: unknown) => () => factory,
|
||||
defaultInlineContentSpecs: {},
|
||||
filterSuggestionItems: vi.fn(() => []),
|
||||
createVideoBlockConfig: vi.fn(() => ({})), defaultInlineContentSpecs: {},
|
||||
filterSuggestionItems: vi.fn(() => []), videoParse: vi.fn(() => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/code-block', () => ({
|
||||
codeBlockOptions: {},
|
||||
}))
|
||||
vi.mock('@blocknote/code-block', () => ({ codeBlockOptions: {} }))
|
||||
|
||||
vi.mock('@blocknote/core/extensions', () => ({
|
||||
filterSuggestionItems: vi.fn(() => []),
|
||||
}))
|
||||
vi.mock('@blocknote/core/extensions', () => ({ filterSuggestionItems: vi.fn(() => []) }))
|
||||
|
||||
vi.mock('@blocknote/react', () => ({
|
||||
AudioBlock: () => null, AudioToExternalHTML: () => null,
|
||||
createReactBlockSpec: () => () => ({}),
|
||||
createReactInlineContentSpec: () => ({ render: () => null }),
|
||||
VideoBlock: () => null, VideoToExternalHTML: () => null,
|
||||
BlockNoteViewRaw: ({ children, editable }: { children?: ReactNode; editable?: boolean }) => (
|
||||
<div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>
|
||||
<div contentEditable={editable !== false} suppressContentEditableWarning data-testid="mock-editor">
|
||||
@@ -409,6 +408,7 @@ vi.mock('@blocknote/react', () => ({
|
||||
DeleteLinkButton: () => null,
|
||||
SideMenuController: () => null,
|
||||
SuggestionMenuController: () => null,
|
||||
GridSuggestionMenuController: () => null,
|
||||
useComponentsContext: () => ({
|
||||
LinkToolbar: {
|
||||
Button: ({
|
||||
@@ -525,7 +525,7 @@ describe('App', () => {
|
||||
// Entries appear in both Sidebar and NoteList
|
||||
expect(screen.getAllByText('Test Project').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Software Development').length).toBeGreaterThan(0)
|
||||
})
|
||||
}, { timeout: SLOW_APP_READY_TIMEOUT_MS })
|
||||
})
|
||||
|
||||
it('keeps the app shell usable while the vault note scan is pending', async () => {
|
||||
@@ -576,7 +576,7 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a note window by loading the requested entry and vault index', async () => {
|
||||
it('opens a note window by loading only the requested entry', async () => {
|
||||
const listVault = vi.fn(() => mockEntries)
|
||||
const reloadVaultEntry = vi.fn(({ path }: { path: string }) =>
|
||||
mockEntries.find((entry) => entry.path === path) ?? null,
|
||||
@@ -599,7 +599,7 @@ describe('App', () => {
|
||||
expect(getNoteContent).toHaveBeenCalledWith({ path: '/vault/project/test.md', vaultPath: '/vault' })
|
||||
await waitFor(() => expect(window.__laputaTest?.activeTabPath).toBe('/vault/project/test.md'))
|
||||
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
|
||||
expect(listVault).toHaveBeenCalledWith({ path: '/vault' })
|
||||
expect(listVault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows keyboard shortcut hints', async () => {
|
||||
@@ -630,9 +630,7 @@ describe('App', () => {
|
||||
it('persists a Cmd+N note before opening it in the editor', async () => {
|
||||
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
|
||||
let resolveSave!: () => void
|
||||
const saveNoteContent = vi.fn(() => new Promise<void>((resolve) => {
|
||||
resolveSave = resolve
|
||||
}))
|
||||
const saveNoteContent = vi.fn(() => new Promise<void>((resolve) => { resolveSave = resolve }))
|
||||
mockCommandResults.save_note_content = saveNoteContent
|
||||
|
||||
try {
|
||||
@@ -645,6 +643,7 @@ describe('App', () => {
|
||||
expect(saveNoteContent).toHaveBeenCalledWith({
|
||||
path: '/vault/untitled-note-1700000000.md',
|
||||
content: '---\ntype: Note\n---\n\n# \n\n',
|
||||
vaultPath: '/vault',
|
||||
})
|
||||
})
|
||||
expect(window.__laputaTest?.activeTabPath).not.toBe('/vault/untitled-note-1700000000.md')
|
||||
@@ -1302,7 +1301,7 @@ describe('App', () => {
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByTestId('status-missing-git')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('status-missing-git', {}, { timeout: SLOW_APP_READY_TIMEOUT_MS })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('status-missing-git'))
|
||||
expect(await screen.findByText('Enable Git for this vault?')).toBeInTheDocument()
|
||||
|
||||
|
||||
127
src/App.tsx
127
src/App.tsx
@@ -74,19 +74,20 @@ import {
|
||||
import { createViewFilename } from './utils/viewFilename'
|
||||
import { nextViewOrder } from './utils/viewOrdering'
|
||||
import { viewMatchesSelection, viewVaultPath } from './utils/viewIdentity'
|
||||
import { viewCreationVaultPath } from './utils/viewTargetVault'
|
||||
import { ConflictResolverModal } from './components/ConflictResolverModal'
|
||||
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
|
||||
import { DeleteProgressNotice } from './components/DeleteProgressNotice'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import type { SidebarSelection, InboxPeriod, ModifiedFile, VaultEntry, ViewDefinition, ViewFile, WorkspaceIdentity } from './types'
|
||||
import type { GitSetupPreference, SidebarSelection, InboxPeriod, ModifiedFile, VaultEntry, ViewDefinition, ViewFile, WorkspaceIdentity } from './types'
|
||||
import type { NoteListItem } from './utils/ai-context'
|
||||
import { initializeNoteProperties } from './utils/initializeNoteProperties'
|
||||
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
import { isWindows } from './utils/platform'
|
||||
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
|
||||
import { getPulledVaultUpdateOptions, refreshPulledVaultState } from './utils/pulledVaultRefresh'
|
||||
import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
|
||||
import { GitSetupDialog } from './components/GitRequiredModal'
|
||||
import { RenameDetectedBanner } from './components/RenameDetectedBanner'
|
||||
@@ -95,6 +96,7 @@ import type { NoteListMultiSelectionCommands } from './components/note-list/mult
|
||||
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import { areAiFeaturesEnabled } from './lib/aiFeatures'
|
||||
import { areGitFeaturesEnabled } from './lib/gitSettings'
|
||||
import { useAppCommandAiActions } from './hooks/useAppCommandAiActions'
|
||||
import { TOLARIA_DOCS_URL } from './constants/feedback'
|
||||
import { openExternalUrl } from './utils/url'
|
||||
@@ -128,6 +130,7 @@ import { useVaultRenameDetection } from './hooks/useVaultRenameDetection'
|
||||
import { useVaultOpenedTelemetry } from './hooks/useVaultOpenedTelemetry'
|
||||
import { useStartupScreenState } from './hooks/useStartupScreenState'
|
||||
import { useGitFileWorkflows } from './hooks/useGitFileWorkflows'
|
||||
import { useAutoGitWork } from './hooks/useAutoGitWork'
|
||||
import './App.css'
|
||||
|
||||
const ACTIVE_EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .raw-editor-codemirror'
|
||||
@@ -355,20 +358,28 @@ function App() {
|
||||
vaultSwitcherLoaded: vaultSwitcher.loaded,
|
||||
windowMode: Boolean(noteWindowParams),
|
||||
})
|
||||
const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath)
|
||||
const gitFeaturesEnabled = areGitFeaturesEnabled(settings)
|
||||
const handleGitSetupPreferenceChange = useCallback((preference: GitSetupPreference) => {
|
||||
updateConfig('git_setup_preference', preference)
|
||||
}, [updateConfig])
|
||||
const {
|
||||
dismissGitSetupDialog,
|
||||
gitRepoState,
|
||||
handleInitGitRepo,
|
||||
neverForVaultGitSetupDialog,
|
||||
openGitSetupDialog,
|
||||
shouldShowGitSetupDialog,
|
||||
} = useGitSetupState({
|
||||
gitSetupPreference: vaultConfig.git_setup_preference,
|
||||
onGitSetupPreferenceChange: handleGitSetupPreferenceChange,
|
||||
onToast: setToastMessage,
|
||||
resolvedPath,
|
||||
windowMode: Boolean(noteWindowParams),
|
||||
})
|
||||
|
||||
const vault = useVaultLoader(
|
||||
resolvedPath,
|
||||
noteWindowParams ? '' : resolvedPath,
|
||||
graphVaults,
|
||||
multiWorkspaceEnabled ? defaultWorkspacePath : null,
|
||||
folderVaults,
|
||||
@@ -411,7 +422,6 @@ function App() {
|
||||
aiFeaturesEnabled ? resolvedPath : null,
|
||||
buildVaultAiGuidanceRefreshKey(vault.entries),
|
||||
)
|
||||
const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath)
|
||||
const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization)
|
||||
const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization)
|
||||
const isChangesSelection = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'changes'
|
||||
@@ -473,24 +483,28 @@ function App() {
|
||||
const loadDefaultVaultModifiedFiles = vault.loadModifiedFiles
|
||||
const loadAllGitModifiedFiles = gitSurfaces.loadAllModifiedFiles
|
||||
const loadModifiedFilesForRepository = gitSurfaces.loadModifiedFilesForRepository
|
||||
const refreshAllGitRemoteStatuses = gitSurfaces.refreshAllRemoteStatuses
|
||||
const refreshRemoteStatusForRepository = gitSurfaces.refreshRemoteStatusForRepository
|
||||
const refreshGitRemoteStatus = useCallback(
|
||||
() => refreshRemoteStatusForRepository(resolvedPath),
|
||||
[refreshRemoteStatusForRepository, resolvedPath],
|
||||
)
|
||||
const refreshGitModifiedFiles = useCallback(async () => {
|
||||
if (!gitFeaturesEnabled) return
|
||||
await Promise.all([
|
||||
loadDefaultVaultModifiedFiles(),
|
||||
loadAllGitModifiedFiles({ includeStats: isChangesSelection }),
|
||||
])
|
||||
}, [isChangesSelection, loadAllGitModifiedFiles, loadDefaultVaultModifiedFiles])
|
||||
}, [gitFeaturesEnabled, isChangesSelection, loadAllGitModifiedFiles, loadDefaultVaultModifiedFiles])
|
||||
const loadVaultModifiedFiles = refreshGitModifiedFiles
|
||||
|
||||
useEffect(() => {
|
||||
if (!gitFeaturesEnabled) return
|
||||
if (gitRepoState !== 'ready') return
|
||||
void loadVaultModifiedFiles()
|
||||
void refreshGitRemoteStatus()
|
||||
}, [gitRepoState, loadVaultModifiedFiles, refreshGitRemoteStatus])
|
||||
void refreshAllGitRemoteStatuses()
|
||||
}, [gitFeaturesEnabled, gitRepoState, loadVaultModifiedFiles, refreshAllGitRemoteStatuses, refreshGitRemoteStatus])
|
||||
|
||||
const openMcpSetupDialog = useCallback(() => {
|
||||
setShowMcpSetupDialog(true)
|
||||
@@ -602,6 +616,7 @@ function App() {
|
||||
onMissingActiveVault: handleMissingActiveVault,
|
||||
onTypeStateChanged: async () => { await vault.reloadVault() },
|
||||
replaceEntry: vault.replaceEntry,
|
||||
onInternalVaultWrite: markRecentVaultWrite,
|
||||
onFrontmatterPersisted: refreshGitModifiedFiles,
|
||||
onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath),
|
||||
})
|
||||
@@ -621,8 +636,9 @@ function App() {
|
||||
})
|
||||
const handleVaultUpdate = useCallback(async (
|
||||
updatedFiles: string[],
|
||||
options: { preserveFocusedEditor?: boolean } = {},
|
||||
options: { preserveFocusedEditor?: boolean; vaultPath?: string } = {},
|
||||
) => {
|
||||
const updateVaultPath = options.vaultPath ?? resolvedPath
|
||||
await refreshPulledVaultState({
|
||||
activeTabPath: notes.activeTabPath,
|
||||
closeAllTabs,
|
||||
@@ -636,7 +652,7 @@ function App() {
|
||||
reloadViews: vault.reloadViews,
|
||||
replaceActiveTab: handleReplaceActiveTab,
|
||||
updatedFiles,
|
||||
vaultPath: resolvedPath,
|
||||
vaultPath: updateVaultPath,
|
||||
})
|
||||
await refreshGitModifiedFiles()
|
||||
}, [
|
||||
@@ -652,7 +668,10 @@ function App() {
|
||||
vault.unsavedPaths,
|
||||
])
|
||||
const handlePulledVaultUpdate = useCallback(
|
||||
(updatedFiles: string[]) => handleVaultUpdate(updatedFiles),
|
||||
(updatedFiles: string[], vaultPath: string) => handleVaultUpdate(updatedFiles, {
|
||||
...getPulledVaultUpdateOptions(),
|
||||
vaultPath,
|
||||
}),
|
||||
[handleVaultUpdate],
|
||||
)
|
||||
const handleFocusedVaultUpdate = useCallback(
|
||||
@@ -678,8 +697,9 @@ function App() {
|
||||
filterChangedPaths: filterExternalVaultPaths,
|
||||
})
|
||||
const autoSync = useAutoSync({
|
||||
enabled: gitRepoState === 'ready',
|
||||
vaultPath: resolvedPath,
|
||||
enabled: gitFeaturesEnabled && gitRepoState === 'ready',
|
||||
vaultPath: gitSurfaces.syncRepositoryPath,
|
||||
vaultPaths: activeGitRepositoryPaths,
|
||||
intervalMinutes: settings.auto_pull_interval_minutes,
|
||||
onVaultUpdated: handlePulledVaultUpdate,
|
||||
onConflict: (files) => {
|
||||
@@ -732,7 +752,8 @@ function App() {
|
||||
})
|
||||
|
||||
const conflictFlow = useConflictFlow({
|
||||
resolvedPath, entries: visibleEntries,
|
||||
resolvedPath: autoSync.conflictVaultPath ?? graphDefaultWorkspacePath,
|
||||
entries: visibleEntries,
|
||||
conflictFiles: autoSync.conflictFiles,
|
||||
pausePull: autoSync.pausePull, resumePull: autoSync.resumePull,
|
||||
triggerSync: autoSync.triggerSync, reloadVault: vault.reloadVault,
|
||||
@@ -812,11 +833,13 @@ function App() {
|
||||
onSetFilter: (filterType) => {
|
||||
handleSetSelection({ kind: 'sectionGroup', type: filterType })
|
||||
},
|
||||
onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : []) },
|
||||
onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : [], resolvedPath) },
|
||||
})
|
||||
|
||||
const handleInitializeProperties = useCallback(async (path: string) => {
|
||||
await initializeNoteProperties(notes.handleUpdateFrontmatter, path)
|
||||
const handleInitializeProperties = useCallback((path: string) => {
|
||||
void initializeNoteProperties(notes.handleUpdateFrontmatter, path).catch((err) => {
|
||||
console.warn('Failed to initialize note properties:', err)
|
||||
})
|
||||
}, [notes])
|
||||
|
||||
const handleRemoveNoteIcon = useCallback(async (path: string) => {
|
||||
@@ -925,7 +948,7 @@ function App() {
|
||||
const selectedChangesModifiedFiles = gitSurfaces.changesModifiedFiles
|
||||
const commitModifiedFiles = gitSurfaces.commitModifiedFiles
|
||||
const changesRepositoryPath = gitSurfaces.changesRepositoryPath
|
||||
const gitModifiedCount = allGitModifiedFiles.length
|
||||
const gitModifiedCount = gitFeaturesEnabled ? allGitModifiedFiles.length : 0
|
||||
|
||||
const {
|
||||
activeDeletedFile,
|
||||
@@ -967,24 +990,29 @@ function App() {
|
||||
resolveRemoteStatusForVaultPath: refreshRemoteStatusForRepository,
|
||||
setToastMessage,
|
||||
onPushRejected: autoSync.handlePushRejected,
|
||||
automaticVaultPaths: activeGitRepositoryPaths,
|
||||
automaticVaultPaths: gitFeaturesEnabled ? activeGitRepositoryPaths : [],
|
||||
locale: appLocale,
|
||||
manualVaultPath: gitSurfaces.commitRepositoryPath,
|
||||
vaultPath: resolvedPath,
|
||||
})
|
||||
const suggestedCommitMessage = useMemo(() => generateCommitMessage(commitModifiedFiles), [commitModifiedFiles])
|
||||
const isGitVault = gitRepoState !== 'missing'
|
||||
const modifiedFilesSignature = useMemo(
|
||||
() => allGitModifiedFiles.map((file) => `${file.vaultPath ?? ''}:${file.relativePath}:${file.status}`).sort().join('|'),
|
||||
[allGitModifiedFiles],
|
||||
)
|
||||
const isGitVault = gitFeaturesEnabled && gitRepoState !== 'missing'
|
||||
const {
|
||||
activitySignature: autoGitActivitySignature,
|
||||
hasPendingWork: autoGitHasPendingWork,
|
||||
} = useAutoGitWork({
|
||||
activeRemoteStatus: autoSync.remoteStatus,
|
||||
activeVaultPath: resolvedPath,
|
||||
modifiedFiles: allGitModifiedFiles,
|
||||
repositoryPaths: activeGitRepositoryPaths,
|
||||
remoteStatusForRepository: gitSurfaces.remoteStatusForRepository,
|
||||
})
|
||||
const autoGit = useAutoGit({
|
||||
enabled: settings.autogit_enabled === true,
|
||||
idleThresholdSeconds: settings.autogit_idle_threshold_seconds ?? 90,
|
||||
inactiveThresholdSeconds: settings.autogit_inactive_threshold_seconds ?? 30,
|
||||
isGitVault,
|
||||
hasPendingChanges: gitModifiedCount > 0
|
||||
|| ((autoSync.remoteStatus?.hasRemote ?? false) && (autoSync.remoteStatus?.ahead ?? 0) > 0),
|
||||
hasPendingChanges: autoGitHasPendingWork,
|
||||
hasUnsavedChanges: vault.unsavedPaths.size > 0,
|
||||
onCheckpoint: () => commitFlow.runAutomaticCheckpoint(),
|
||||
})
|
||||
@@ -994,24 +1022,39 @@ function App() {
|
||||
const handleAppContentChange = appSave.handleContentChange
|
||||
const handleAppSave = appSave.handleSave
|
||||
const loadModifiedFiles = refreshGitModifiedFiles
|
||||
const triggerSync = autoSync.triggerSync
|
||||
const pullAndPush = autoSync.pullAndPush
|
||||
|
||||
useEffect(() => {
|
||||
if (!gitFeaturesEnabled) return
|
||||
if (!isChangesSelection) return
|
||||
void loadModifiedFilesForRepository(changesRepositoryPath, { includeStats: true })
|
||||
}, [changesRepositoryPath, isChangesSelection, loadModifiedFilesForRepository])
|
||||
}, [changesRepositoryPath, gitFeaturesEnabled, isChangesSelection, loadModifiedFilesForRepository])
|
||||
|
||||
useEffect(() => {
|
||||
if (modifiedFilesSignature.length === 0) return
|
||||
if (autoGitActivitySignature.length === 0) return
|
||||
recordAutoGitActivity()
|
||||
}, [modifiedFilesSignature, recordAutoGitActivity])
|
||||
}, [autoGitActivitySignature, recordAutoGitActivity])
|
||||
|
||||
const handleCommitPush = useCallback(() => {
|
||||
if (!gitFeaturesEnabled) return
|
||||
triggerCommitEntryAction({
|
||||
autoGitEnabled: settings.autogit_enabled === true,
|
||||
openCommitDialog,
|
||||
runAutomaticCheckpoint,
|
||||
})
|
||||
}, [openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled])
|
||||
}, [gitFeaturesEnabled, openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled])
|
||||
const handlePullRepository = useCallback((targetVaultPath: string) => {
|
||||
if (!gitFeaturesEnabled) return
|
||||
triggerSync(targetVaultPath)
|
||||
}, [gitFeaturesEnabled, triggerSync])
|
||||
const handlePullSelectedRepository = useCallback(() => {
|
||||
if (!gitFeaturesEnabled) return
|
||||
triggerSync()
|
||||
}, [gitFeaturesEnabled, triggerSync])
|
||||
const handlePullAndPushSelectedRepository = useCallback(() => {
|
||||
pullAndPush()
|
||||
}, [pullAndPush])
|
||||
|
||||
const handleTrackedContentChange = useCallback((path: string, content: string) => {
|
||||
recordAutoGitActivity()
|
||||
@@ -1155,8 +1198,13 @@ function App() {
|
||||
|
||||
const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => {
|
||||
const editing = dialogs.editingView
|
||||
const activeVaultViews = viewsForVault(vault.views, resolvedPath)
|
||||
const targetVaultPath = editing?.rootPath ?? resolvedPath
|
||||
const targetVaultPath = viewCreationVaultPath({
|
||||
editingRootPath: editing?.rootPath,
|
||||
fallbackVaultPath: resolvedPath,
|
||||
graphDefaultWorkspacePath,
|
||||
multiWorkspaceEnabled,
|
||||
})
|
||||
const activeVaultViews = viewsForVault(vault.views, targetVaultPath)
|
||||
const filename = savedViewFilename(definition, editing, activeVaultViews)
|
||||
const nextDefinition = savedViewDefinition(definition, editing, activeVaultViews)
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
@@ -1177,7 +1225,7 @@ function App() {
|
||||
setToastMessage(`Could not save view: ${message}`)
|
||||
return false
|
||||
}
|
||||
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView, setToastMessage])
|
||||
}, [graphDefaultWorkspacePath, multiWorkspaceEnabled, resolvedPath, vault, handleSetSelection, dialogs.editingView, setToastMessage])
|
||||
|
||||
const handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial<ViewDefinition>, rootPath?: string) => {
|
||||
const existing = vault.views.find((view) => viewMatchesSelection(view, viewSelection(filename, rootPath)))
|
||||
@@ -1526,9 +1574,12 @@ function App() {
|
||||
onDeleteNote: deleteActions.handleDeleteNote,
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: handleCommitPush,
|
||||
gitRepositories,
|
||||
gitFeaturesEnabled,
|
||||
isGitVault,
|
||||
onInitializeGit: openGitSetupDialog,
|
||||
onPull: autoSync.triggerSync,
|
||||
onPull: handlePullSelectedRepository,
|
||||
onPullRepository: handlePullRepository,
|
||||
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
|
||||
onSetViewMode: handleSetViewMode,
|
||||
onToggleInspector: handleToggleInspector,
|
||||
@@ -1676,7 +1727,7 @@ function App() {
|
||||
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={gitSurfaces.historyRepositoryPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.historyRepositoryPath} onRepositoryChange={gitSurfaces.setHistoryRepositoryPath} locale={appLocale} />
|
||||
) : (
|
||||
<NoteList entries={visibleEntries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={noteListModifiedFiles} modifiedFilesError={noteListModifiedFilesError} gitRepositories={gitRepositories} selectedGitRepositoryPath={gitSurfaces.changesRepositoryPath} onGitRepositoryChange={gitSurfaces.setChangesRepositoryPath} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} allNotesFileVisibility={allNotesFileVisibility} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
|
||||
<NoteList entries={visibleEntries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={noteListModifiedFiles} modifiedFilesError={noteListModifiedFilesError} gitRepositories={gitRepositories} selectedGitRepositoryPath={gitSurfaces.changesRepositoryPath} onGitRepositoryChange={gitSurfaces.setChangesRepositoryPath} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onToggleFavorite={entryActions.handleToggleFavorite} onToggleOrganized={explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined} onRevealFile={fileActions.revealFile} onCopyFilePath={fileActions.copyFilePath} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} allNotesFileVisibility={allNotesFileVisibility} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -1687,7 +1738,7 @@ function App() {
|
||||
tabs={notes.tabs}
|
||||
activeTabPath={notes.activeTabPath}
|
||||
isVaultLoading={isVaultContentLoading}
|
||||
entries={visibleEntries}
|
||||
entries={noteWindowParams && activeTab ? [activeTab.entry] : visibleEntries}
|
||||
onNavigateWikilink={notes.handleNavigateWikilink}
|
||||
onLoadDiff={loadDiffForPath}
|
||||
onLoadDiffAtCommit={loadDiffAtCommitForPath}
|
||||
@@ -1758,11 +1809,11 @@ function App() {
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} 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} commitActionPending={commitFlow.isOpeningCommitDialog} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} 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} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
|
||||
<GitSetupDialog open={shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
|
||||
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} 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} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.syncRepositoryPath} onRepositoryChange={gitSurfaces.setSyncRepositoryPath} onTriggerSync={handlePullSelectedRepository} onPullAndPush={handlePullAndPushSelectedRepository} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
|
||||
<GitSetupDialog open={gitFeaturesEnabled && shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} onNeverForVault={neverForVaultGitSetupDialog} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={visibleEntries} isLoading={vault.isLoading} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} locale={appLocale} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={visibleEntries} isLoading={vault.isLoading} onSelect={notes.handleSelectNote} onCreateNote={(title) => notes.handleCreateNote(title, 'Note', 'quick_open')} onClose={dialogs.closeQuickOpen} locale={appLocale} />
|
||||
<CommandPalette
|
||||
open={dialogs.showCommandPalette}
|
||||
commands={commands}
|
||||
@@ -1808,7 +1859,7 @@ function App() {
|
||||
onCommit={conflictResolver.commitResolution}
|
||||
onClose={conflictFlow.handleCloseConflictResolver}
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} initialSectionId={settingsInitialSectionId} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} vaults={vaultSwitcher.allVaults} defaultWorkspacePath={vaultSwitcher.defaultWorkspacePath} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onRemoveVault={vaultSwitcher.removeVault} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} isGitVault={isGitVault} onSave={saveSettings} onCopyMcpConfig={handleCopyMcpConfig} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<SettingsPanel open={dialogs.showSettings} initialSectionId={settingsInitialSectionId} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} vaults={vaultSwitcher.allVaults} defaultWorkspacePath={vaultSwitcher.defaultWorkspacePath} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} isGitVault={gitRepoState !== 'missing'} onSave={saveSettings} onCopyMcpConfig={handleCopyMcpConfig} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
|
||||
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} manualConfigSnippet={mcpConfigSnippet} manualConfigLoading={mcpConfigLoading} manualConfigError={mcpConfigError} locale={appLocale} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onCopyManualConfig={handleCopyMcpConfig} onDisconnect={handleDisconnectMcp} onLoadManualConfig={handleLoadMcpConfigSnippet} />
|
||||
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
|
||||
|
||||
148
src/NoteWindowApp.test.tsx
Normal file
148
src/NoteWindowApp.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import NoteWindowApp from './NoteWindowApp'
|
||||
import type { NoteStatus, VaultEntry } from './types'
|
||||
|
||||
type MockEditorProps = {
|
||||
activeTabPath: string | null
|
||||
entries: VaultEntry[]
|
||||
getNoteStatus?: (path: string) => NoteStatus
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
tabs: Array<{ entry: VaultEntry; content: string }>
|
||||
vaultPath?: string
|
||||
vaultPaths?: string[]
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
editorProps: [] as MockEditorProps[],
|
||||
invoke: vi.fn(),
|
||||
setTitle: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: mocks.invoke,
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => ({ setTitle: mocks.setTitle }),
|
||||
}))
|
||||
|
||||
vi.mock('./mock-tauri', () => ({
|
||||
isTauri: () => true,
|
||||
mockInvoke: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./components/Editor', () => ({
|
||||
Editor: (props: MockEditorProps) => {
|
||||
mocks.editorProps.push(props)
|
||||
return (
|
||||
<button
|
||||
data-testid="mock-note-window-editor"
|
||||
type="button"
|
||||
onClick={() => props.onContentChange?.(props.activeTabPath ?? '', 'Updated content')}
|
||||
>
|
||||
{props.tabs[0]?.content}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./components/Toast', () => ({
|
||||
Toast: ({ message }: { message: string | null }) => (
|
||||
<div data-testid="mock-toast">{message}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
function makeEntry(): VaultEntry {
|
||||
return {
|
||||
path: 'Notes/entry.md',
|
||||
filename: 'entry.md',
|
||||
title: 'Entry',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1_700_000_000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: '',
|
||||
wordCount: 10,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: true,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
}
|
||||
}
|
||||
|
||||
function setSearch(search: string) {
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { ...window.location, search },
|
||||
})
|
||||
}
|
||||
|
||||
describe('NoteWindowApp', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'warn').mockImplementation(mocks.warn)
|
||||
mocks.editorProps.length = 0
|
||||
setSearch('?window=note&path=%2Fvault%2FNotes%2Fentry.md&vault=%2Fvault&title=Entry')
|
||||
mocks.invoke.mockImplementation(async (command: string) => {
|
||||
if (command === 'sync_vault_asset_scope_for_window') return undefined
|
||||
if (command === 'reload_vault_entry') return makeEntry()
|
||||
if (command === 'get_note_content') return '# Entry'
|
||||
if (command === 'save_note_content') return undefined
|
||||
throw new Error(`Unexpected command: ${command}`)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('loads one note without booting the full vault and saves editor changes', async () => {
|
||||
render(<NoteWindowApp />)
|
||||
const editor = await screen.findByTestId('mock-note-window-editor')
|
||||
|
||||
expect(editor).toHaveTextContent('# Entry')
|
||||
expect(mocks.editorProps.at(-1)).toEqual(expect.objectContaining({
|
||||
activeTabPath: 'Notes/entry.md',
|
||||
vaultPath: '/vault',
|
||||
vaultPaths: ['/vault'],
|
||||
}))
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith('list_vault', expect.anything())
|
||||
|
||||
vi.useFakeTimers()
|
||||
fireEvent.click(editor)
|
||||
expect(mocks.editorProps.at(-1)?.getNoteStatus?.('Notes/entry.md')).toBe('unsaved')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(700)
|
||||
})
|
||||
vi.useRealTimers()
|
||||
|
||||
expect(mocks.invoke).toHaveBeenCalledWith('save_note_content', {
|
||||
path: 'Notes/entry.md',
|
||||
content: 'Updated content',
|
||||
vaultPath: '/vault',
|
||||
})
|
||||
})
|
||||
})
|
||||
199
src/NoteWindowApp.tsx
Normal file
199
src/NoteWindowApp.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { Editor } from './components/Editor'
|
||||
import { Toast } from './components/Toast'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import type { NoteStatus, VaultEntry } from './types'
|
||||
import {
|
||||
getNoteWindowPathCandidates,
|
||||
getNoteWindowParams,
|
||||
type NoteWindowParams,
|
||||
} from './utils/windowMode'
|
||||
import './App.css'
|
||||
|
||||
const NOTE_WINDOW_SAVE_DELAY_MS = 700
|
||||
const MISSING_NOTE_WINDOW_PARAMS_MESSAGE = ''
|
||||
const noop = () => {}
|
||||
|
||||
type NoteWindowLoadState =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'ready'; entry: VaultEntry; content: string }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
type ReadyNoteWindowState = Extract<NoteWindowLoadState, { kind: 'ready' }>
|
||||
type SaveRequest = { path: string; content: string; vaultPath: string }
|
||||
type NoteWindowCommand =
|
||||
| 'get_note_content'
|
||||
| 'reload_vault_entry'
|
||||
| 'save_note_content'
|
||||
| 'sync_vault_asset_scope_for_window'
|
||||
|
||||
function tauriCall<T>(command: NoteWindowCommand, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
async function resolveNoteWindowEntry(params: NoteWindowParams): Promise<VaultEntry | null> {
|
||||
for (const path of getNoteWindowPathCandidates(params)) {
|
||||
try {
|
||||
const entry = await tauriCall<VaultEntry | null>('reload_vault_entry', {
|
||||
path,
|
||||
vaultPath: params.vaultPath,
|
||||
})
|
||||
if (entry) return entry
|
||||
} catch {
|
||||
// Keep trying normalized path candidates before surfacing a load failure.
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function loadNoteWindow(params: NoteWindowParams): Promise<Extract<NoteWindowLoadState, { kind: 'ready' }>> {
|
||||
await tauriCall('sync_vault_asset_scope_for_window', { vaultPath: params.vaultPath }).catch(() => undefined)
|
||||
const entry = await resolveNoteWindowEntry(params)
|
||||
if (!entry) throw new Error(`Could not open "${params.noteTitle}" in this window`)
|
||||
const content = await tauriCall<string>('get_note_content', {
|
||||
path: entry.path,
|
||||
vaultPath: params.vaultPath,
|
||||
})
|
||||
return { kind: 'ready', entry, content }
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function initialNoteWindowState(params: NoteWindowParams | null): NoteWindowLoadState {
|
||||
return params ? { kind: 'loading' } : { kind: 'error', message: MISSING_NOTE_WINDOW_PARAMS_MESSAGE }
|
||||
}
|
||||
|
||||
function useNoteWindowState(params: NoteWindowParams | null): NoteWindowLoadState {
|
||||
const [state, setState] = useState<NoteWindowLoadState>(() => initialNoteWindowState(params))
|
||||
|
||||
useEffect(() => {
|
||||
if (!params) return
|
||||
let cancelled = false
|
||||
|
||||
loadNoteWindow(params)
|
||||
.then((nextState) => {
|
||||
if (!cancelled) setState(nextState)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) setState({ kind: 'error', message: errorMessage(error) })
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [params])
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
function getWindowTitle(state: NoteWindowLoadState, params: NoteWindowParams | null): string | null {
|
||||
return state.kind === 'ready' ? state.entry.title : params?.noteTitle ?? null
|
||||
}
|
||||
|
||||
function useNoteWindowTitle(state: NoteWindowLoadState, params: NoteWindowParams | null): void {
|
||||
useEffect(() => {
|
||||
const title = getWindowTitle(state, params)
|
||||
if (!title) return
|
||||
|
||||
document.title = title
|
||||
if (!isTauri()) return
|
||||
|
||||
import('@tauri-apps/api/window')
|
||||
.then(({ getCurrentWindow }) => getCurrentWindow().setTitle(title))
|
||||
.catch((error) => console.warn('[window] Failed to update note window title:', error))
|
||||
}, [params, state])
|
||||
}
|
||||
|
||||
function clearSaveTimer(saveTimerRef: MutableRefObject<number | null>): void {
|
||||
if (saveTimerRef.current === null) return
|
||||
|
||||
window.clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
|
||||
function saveNoteWindowContent(request: SaveRequest, setStatus: (status: NoteStatus) => void): void {
|
||||
setStatus('pendingSave')
|
||||
void tauriCall('save_note_content', request)
|
||||
.then(() => setStatus('clean'))
|
||||
.catch((error) => {
|
||||
console.warn('[window] Failed to save note window content:', error)
|
||||
setStatus('unsaved')
|
||||
})
|
||||
}
|
||||
|
||||
function useDebouncedNoteWindowSave(vaultPath: string | null, setStatus: (status: NoteStatus) => void) {
|
||||
const saveTimerRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => () => clearSaveTimer(saveTimerRef), [])
|
||||
|
||||
return useCallback((path: string, content: string) => {
|
||||
if (!vaultPath) return
|
||||
setStatus('unsaved')
|
||||
clearSaveTimer(saveTimerRef)
|
||||
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
saveTimerRef.current = null
|
||||
saveNoteWindowContent({ path, content, vaultPath }, setStatus)
|
||||
}, NOTE_WINDOW_SAVE_DELAY_MS)
|
||||
}, [setStatus, vaultPath])
|
||||
}
|
||||
|
||||
function NoteWindowEditor({ params, state }: {
|
||||
params: NoteWindowParams
|
||||
state: ReadyNoteWindowState
|
||||
}) {
|
||||
const [tabContent, setTabContent] = useState(state.content)
|
||||
const [noteStatus, setNoteStatus] = useState<NoteStatus>('clean')
|
||||
const saveContent = useDebouncedNoteWindowSave(params.vaultPath, setNoteStatus)
|
||||
const tab = useMemo(() => ({ entry: state.entry, content: tabContent }), [state.entry, tabContent])
|
||||
const updateContent = useCallback((path: string, content: string) => {
|
||||
setTabContent(content)
|
||||
saveContent(path, content)
|
||||
}, [saveContent])
|
||||
|
||||
return (
|
||||
<Editor
|
||||
tabs={[tab]}
|
||||
activeTabPath={state.entry.path}
|
||||
entries={[state.entry]}
|
||||
onNavigateWikilink={noop}
|
||||
inspectorCollapsed
|
||||
onToggleInspector={noop}
|
||||
inspectorWidth={320}
|
||||
onInspectorResize={noop}
|
||||
inspectorEntry={state.entry}
|
||||
inspectorContent={tabContent}
|
||||
gitHistory={[]}
|
||||
onContentChange={updateContent}
|
||||
getNoteStatus={() => noteStatus}
|
||||
vaultPath={params.vaultPath}
|
||||
vaultPaths={[params.vaultPath]}
|
||||
locale="en"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NoteWindowApp() {
|
||||
const params = useMemo(() => getNoteWindowParams(), [])
|
||||
const state = useNoteWindowState(params)
|
||||
useNoteWindowTitle(state, params)
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="app">
|
||||
<div className="app__editor">
|
||||
{state.kind === 'ready' && params ? (
|
||||
<NoteWindowEditor params={params} state={state} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{state.kind === 'error' ? state.message : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{state.kind === 'error' && <Toast message={state.message} onDismiss={noop} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { CalendarBlank as CalendarIcon, Check, X } from '@phosphor-icons/react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { CalendarIcon, Check, X } from 'lucide-react'
|
||||
import {
|
||||
type PropertyDisplayMode,
|
||||
formatDateValue,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ArrowUpRight, Bot, CheckCircle2, Cloud, HardDrive, Loader2, Terminal } from 'lucide-react'
|
||||
import { ArrowUpRight, CheckCircle as CheckCircle2, CircleNotch as Loader2, Cloud, HardDrive, Robot as Bot, Terminal } from '@phosphor-icons/react'
|
||||
import {
|
||||
AI_AGENT_DEFINITIONS,
|
||||
getAiAgentAvailability,
|
||||
getAiAgentDefinition,
|
||||
hasAnyInstalledAiAgent,
|
||||
isAiAgentsStatusChecking,
|
||||
@@ -81,7 +82,7 @@ function AgentStatusList({ statuses }: { statuses: AiAgentsStatus }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{AI_AGENT_DEFINITIONS.map((definition) => {
|
||||
const status = statuses[definition.id]
|
||||
const status = getAiAgentAvailability(statuses, definition.id)
|
||||
const ready = status.status === 'installed'
|
||||
return (
|
||||
<div
|
||||
@@ -113,8 +114,8 @@ export function AiAgentsOnboardingPrompt({
|
||||
onContinue,
|
||||
}: AiAgentsOnboardingPromptProps) {
|
||||
const copy = getPromptCopy(statuses)
|
||||
const showLegacyClaudeCompatibility = statuses.claude_code.status !== 'installed'
|
||||
const missingAgents = AI_AGENT_DEFINITIONS.filter((definition) => statuses[definition.id].status === 'missing')
|
||||
const showLegacyClaudeCompatibility = getAiAgentAvailability(statuses, 'claude_code').status !== 'installed'
|
||||
const missingAgents = AI_AGENT_DEFINITIONS.filter((definition) => getAiAgentAvailability(statuses, definition.id).status === 'missing')
|
||||
|
||||
return (
|
||||
<OnboardingShell
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('BreadcrumbBar filename visibility', () => {
|
||||
it('offsets the editor-only breadcrumb title past the macOS traffic lights', () => {
|
||||
const editorCss = readFileSync(`${process.cwd()}/src/components/Editor.css`, 'utf8')
|
||||
|
||||
expect(editorCss).toContain('.app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar')
|
||||
expect(editorCss).toContain('body.mac-chrome .app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar')
|
||||
expect(editorCss).toContain('--breadcrumb-bar-left-padding: 90px;')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowUpRight, Bot, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
import { ArrowUpRight, CheckCircle as CheckCircle2, CircleNotch as Loader2, Robot as Bot } from '@phosphor-icons/react'
|
||||
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { Warning as AlertTriangle } from '@phosphor-icons/react'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
|
||||
interface ConflictNoteBannerProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Check, CircleNotch as Loader2, FileText, Warning as AlertTriangle } from '@phosphor-icons/react'
|
||||
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, FileText, Check, Loader2 } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import type { ConflictFileState } from '../hooks/useConflictResolver'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CircleNotch as Loader2 } from '@phosphor-icons/react'
|
||||
import { memo } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DeleteProgressNoticeProps {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar {
|
||||
body.mac-chrome .app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar {
|
||||
--breadcrumb-bar-left-padding: 90px;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,30 +49,39 @@ const blockNoteViewState = vi.hoisted(() => ({
|
||||
|
||||
// Mock BlockNote components
|
||||
vi.mock('@blocknote/core', () => ({
|
||||
audioParse: vi.fn(() => undefined),
|
||||
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
|
||||
createAudioBlockConfig: vi.fn(() => ({})),
|
||||
createCodeBlockSpec: vi.fn(() => ({})),
|
||||
createExtension: (factory: unknown) => () => factory,
|
||||
createVideoBlockConfig: vi.fn(() => ({})),
|
||||
defaultInlineContentSpecs: {},
|
||||
filterSuggestionItems: vi.fn(() => []),
|
||||
videoParse: vi.fn(() => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/code-block', () => ({
|
||||
codeBlockOptions: {},
|
||||
}))
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
|
||||
const mockFilterSuggestionItems = vi.fn((...args: any[]) => args[0] ?? [])
|
||||
const mockFilterSuggestionItems = vi.fn((...args: unknown[]) => args[0] ?? [])
|
||||
vi.mock('@blocknote/core/extensions', () => ({
|
||||
filterSuggestionItems: (...args: unknown[]) => mockFilterSuggestionItems(...args),
|
||||
}))
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
|
||||
const capturedGetItemsByTrigger: Record<string, (query: string) => Promise<any[]>> = {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
|
||||
let capturedGetItems: ((query: string) => Promise<any[]>) | null = null
|
||||
type SuggestionControllerProps = {
|
||||
triggerCharacter: string
|
||||
getItems: (query: string) => Promise<unknown[]>
|
||||
}
|
||||
const capturedGetItemsByTrigger: Record<string, (query: string) => Promise<unknown[]>> = {}
|
||||
let capturedGetItems: ((query: string) => Promise<unknown[]>) | null = null
|
||||
vi.mock('@blocknote/react', () => ({
|
||||
AudioBlock: () => null,
|
||||
AudioToExternalHTML: () => null,
|
||||
createReactBlockSpec: () => () => ({}),
|
||||
createReactInlineContentSpec: () => ({ render: () => null }),
|
||||
VideoBlock: () => null,
|
||||
VideoToExternalHTML: () => null,
|
||||
useCreateBlockNote: (options: unknown) => {
|
||||
blockNoteCreation.options.push(options)
|
||||
return mockEditor
|
||||
@@ -106,12 +115,15 @@ vi.mock('@blocknote/react', () => ({
|
||||
EditLinkButton: () => null,
|
||||
DeleteLinkButton: () => null,
|
||||
SideMenuController: () => null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
|
||||
SuggestionMenuController: (props: any) => {
|
||||
SuggestionMenuController: (props: SuggestionControllerProps) => {
|
||||
capturedGetItemsByTrigger[props.triggerCharacter] = props.getItems
|
||||
if (props.triggerCharacter === '[[') capturedGetItems = props.getItems
|
||||
return null
|
||||
},
|
||||
GridSuggestionMenuController: (props: SuggestionControllerProps) => {
|
||||
capturedGetItemsByTrigger[props.triggerCharacter] = props.getItems
|
||||
return null
|
||||
},
|
||||
useComponentsContext: () => ({
|
||||
LinkToolbar: {
|
||||
Button: ({
|
||||
@@ -565,7 +577,7 @@ describe('Editor', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open table of contents' }))
|
||||
|
||||
expect(screen.getByTestId('table-of-contents-panel')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: 'Table Heading' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: 'Table Heading' }, { timeout: 5000 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Regression: editor content did not appear on first load because BlockNote's
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user