Compare commits
238 Commits
alpha-v202
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2a50d9870 | ||
|
|
cca72a9d6a | ||
|
|
ef0e85b053 | ||
|
|
2624d32b50 | ||
|
|
9c42da5745 | ||
|
|
454acb5ded | ||
|
|
2a691f68db | ||
|
|
c4de154410 | ||
|
|
670aae9f2e | ||
|
|
f3b4e5e61d | ||
|
|
4c4f01af43 | ||
|
|
79d7eb9795 | ||
|
|
d1ed826714 | ||
|
|
2e8286d2b3 | ||
|
|
cb9ebaadad | ||
|
|
c295c9f2b5 | ||
|
|
a4b11089c0 | ||
|
|
6f29542528 | ||
|
|
dcd0d73848 | ||
|
|
0e8c8fb61a | ||
|
|
42bdd559ef | ||
|
|
3a6c97272d | ||
|
|
f53a3746d3 | ||
|
|
23145a05f5 | ||
|
|
5b86b0b50d | ||
|
|
dc1b1783b6 | ||
|
|
3dc520abea | ||
|
|
792b3009dd | ||
|
|
6ee7f219f5 | ||
|
|
3db1ed9dc5 | ||
|
|
7a7ef5d9ca | ||
|
|
4c42cf4d52 | ||
|
|
405b711e16 | ||
|
|
83705e26f5 | ||
|
|
434927160c | ||
|
|
ec2e04a464 | ||
|
|
a17ec689a3 | ||
|
|
eda4d380e0 | ||
|
|
f9b155ba6e | ||
|
|
4a62008b1f | ||
|
|
a08cee7965 | ||
|
|
a7c480af62 | ||
|
|
f4790d5763 | ||
|
|
ed52a9e800 | ||
|
|
2fcd808f09 | ||
|
|
9d15286f7d | ||
|
|
537bd0f3ec | ||
|
|
648241604f | ||
|
|
c6f3d9d945 | ||
|
|
65e54f108a | ||
|
|
58f8a774b5 | ||
|
|
0bf280d3ec | ||
|
|
d2d4746d5c | ||
|
|
91db7c0098 | ||
|
|
87908d982f | ||
|
|
40544fc551 | ||
|
|
f34fdb0289 | ||
|
|
e601a42f41 | ||
|
|
de627c2478 | ||
|
|
46d9aaed48 | ||
|
|
6b96b5caf6 | ||
|
|
595c48ca59 | ||
|
|
fd92ff5e9b | ||
|
|
a39b346a75 | ||
|
|
deac10caa2 | ||
|
|
0e1e0ff47a | ||
|
|
a645edbc19 | ||
|
|
48f1fb0b50 | ||
|
|
1aede09834 | ||
|
|
d51b76f992 | ||
|
|
8e66fb4c19 | ||
|
|
a2843a0cf3 | ||
|
|
38fb8a7370 | ||
|
|
be5a8bb300 | ||
|
|
ce040c75fd | ||
|
|
ced89a42bd | ||
|
|
20a960551f | ||
|
|
2e331315e8 | ||
|
|
d2c15b8469 | ||
|
|
2fe6c5b580 | ||
|
|
2a224f78f8 | ||
|
|
b85d54bb5d | ||
|
|
9c05e17d37 | ||
|
|
cf6ad71fff | ||
|
|
db82aee172 | ||
|
|
c72d833624 | ||
|
|
a3ca78fced | ||
|
|
e912ab9a9f | ||
|
|
49717dc671 | ||
|
|
d9a0a04ddb | ||
|
|
0561fe68b1 | ||
|
|
2f078fab11 | ||
|
|
98fde6571a | ||
|
|
a5c4b8fa20 | ||
|
|
1301653b81 | ||
|
|
ee90b05159 | ||
|
|
b6c11b1468 | ||
|
|
2fbdafa6f2 | ||
|
|
39f44b86aa | ||
|
|
cb3274cdbc | ||
|
|
76d8bba332 | ||
|
|
86853cf337 | ||
|
|
9a292d2476 | ||
|
|
4a22b02810 | ||
|
|
e4270cda3b | ||
|
|
9cb0de37a5 | ||
|
|
2a794cb373 | ||
|
|
324af6b271 | ||
|
|
926db0eeb5 | ||
|
|
b1a97834c9 | ||
|
|
da13cb38ac | ||
|
|
dbf8ba5f40 | ||
|
|
3e8a6e5d7c | ||
|
|
8141644729 | ||
|
|
0d88c75718 | ||
|
|
f8721f2a1b | ||
|
|
fb39c6679a | ||
|
|
f1bed131bf | ||
|
|
257cf6ed02 | ||
|
|
908daafaa1 | ||
|
|
d15437face | ||
|
|
30281f879d | ||
|
|
4ef6edfb10 | ||
|
|
918419e371 | ||
|
|
2704002973 | ||
|
|
9265b8f117 | ||
|
|
943fa854e1 | ||
|
|
a2f8ba72a2 | ||
|
|
1d546dde3d | ||
|
|
63877e61ec | ||
|
|
8896f1d451 | ||
|
|
f523099854 | ||
|
|
15f38f096f | ||
|
|
cdd2ddec6c | ||
|
|
292d3739df | ||
|
|
1136c1948e | ||
|
|
8406042db1 | ||
|
|
f07cbbe0eb | ||
|
|
768da9f955 | ||
|
|
f58e9d8930 | ||
|
|
133242eff5 | ||
|
|
aebd2bea4a | ||
|
|
60238fbe78 | ||
|
|
5336236e79 | ||
|
|
f9719f11b5 | ||
|
|
e4083ded1a | ||
|
|
65a89f102a | ||
|
|
5b34ec2980 | ||
|
|
e579979829 | ||
|
|
c7edc71a97 | ||
|
|
5fd8f8fb40 | ||
|
|
694419d442 | ||
|
|
e82fb2a5c7 | ||
|
|
39234dcf52 | ||
|
|
36e8a62284 | ||
|
|
d622b91b81 | ||
|
|
5a546b9a8b | ||
|
|
b8c1a094b6 | ||
|
|
c2c56cfca5 | ||
|
|
449a62e31f | ||
|
|
7d6fd5cc51 | ||
|
|
75ed6460ac | ||
|
|
a3096c596f | ||
|
|
6105c7527c | ||
|
|
92eee0d470 | ||
|
|
f14fda09d5 | ||
|
|
31d12d8cc8 | ||
|
|
6a002f0dc0 | ||
|
|
9d30d1165f | ||
|
|
b709acd58a | ||
|
|
080e3498a7 | ||
|
|
51010ca578 | ||
|
|
15bc3a4701 | ||
|
|
3ebfa642fa | ||
|
|
8207b87c33 | ||
|
|
602a099ac3 | ||
|
|
2315122c4a | ||
|
|
ab4fcdbaae | ||
|
|
a56cb8c287 | ||
|
|
b93e6d491a | ||
|
|
3152ff2c6e | ||
|
|
dd018cf98b | ||
|
|
d0d9d90096 | ||
|
|
944efada94 | ||
|
|
ef4ad256e3 | ||
|
|
c427cd2492 | ||
|
|
151e993ab7 | ||
|
|
60554d0b96 | ||
|
|
4929f11b6d | ||
|
|
3f4e5b585f | ||
|
|
cd49c81bd3 | ||
|
|
b9f3dc9e1a | ||
|
|
122aba7878 | ||
|
|
05896bde19 | ||
|
|
87a933f39c | ||
|
|
54c0efa3e4 | ||
|
|
bdde0c8943 | ||
|
|
a474303eb9 | ||
|
|
4701485ee5 | ||
|
|
135a8a0adf | ||
|
|
0f5d7d5340 | ||
|
|
d6b3c0aef3 | ||
|
|
f896c01829 | ||
|
|
c3cff0c461 | ||
|
|
8c7f9238cb | ||
|
|
38acebba7c | ||
|
|
622977aeb8 | ||
|
|
9ce1c6f854 | ||
|
|
46865638cd | ||
|
|
564ee0a387 | ||
|
|
11655c0283 | ||
|
|
c24fd30266 | ||
|
|
daa1e3e985 | ||
|
|
44c7f43e57 | ||
|
|
f2c83be4b3 | ||
|
|
6104402bfb | ||
|
|
b3744e3bf5 | ||
|
|
67c8598b87 | ||
|
|
52620c80dc | ||
|
|
7e386ff70c | ||
|
|
aba5550b8e | ||
|
|
309b816e6d | ||
|
|
f552d8abff | ||
|
|
9f6779e9f1 | ||
|
|
bddf5106bb | ||
|
|
ba5c165016 | ||
|
|
39415c350f | ||
|
|
499e481df8 | ||
|
|
843cedd14e | ||
|
|
05156ca793 | ||
|
|
2308b269c8 | ||
|
|
b7f482bf27 | ||
|
|
33d7404d6c | ||
|
|
0ecac2ab79 | ||
|
|
7c9f2b0dda | ||
|
|
24e317cfa6 | ||
|
|
66c183fa61 | ||
|
|
dacf9bb19b |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.94
|
||||
AVERAGE_THRESHOLD=9.83
|
||||
HOTSPOT_THRESHOLD=10.0
|
||||
AVERAGE_THRESHOLD=9.91
|
||||
|
||||
@@ -12,3 +12,7 @@ VITE_SENTRY_DSN=
|
||||
# PostHog (https://posthog.com → Project → Settings → Project API Key)
|
||||
VITE_POSTHOG_KEY=
|
||||
VITE_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
|
||||
# Lara CLI (https://github.com/translated/lara-cli)
|
||||
LARA_ACCESS_KEY_ID=
|
||||
LARA_ACCESS_KEY_SECRET=
|
||||
|
||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
custom: https://refactoring.fm/
|
||||
76
.github/workflows/ci.yml
vendored
76
.github/workflows/ci.yml
vendored
@@ -65,22 +65,18 @@ jobs:
|
||||
- name: Vite build check
|
||||
run: pnpm build
|
||||
|
||||
# ── 1. Tests ──────────────────────────────────────────────────────────
|
||||
- name: Run frontend tests
|
||||
run: pnpm test
|
||||
|
||||
# ── 1. Coverage-backed tests ──────────────────────────────────────────
|
||||
# The coverage commands run the same frontend and Rust test suites, so keep
|
||||
# them as the canonical test lane instead of running every suite twice.
|
||||
- name: Bundle MCP server resources (required by Tauri build)
|
||||
run: node scripts/bundle-mcp-server.mjs
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --manifest-path=src-tauri/Cargo.toml
|
||||
|
||||
# ── 2. Coverage (enforced — fails build if thresholds not met) ────────
|
||||
- name: Frontend coverage (≥70% lines/functions/branches/statements)
|
||||
# ── 2. Tests + coverage (enforced — fails build if thresholds not met) ─
|
||||
- name: Frontend tests + coverage (≥70% lines/functions/branches/statements)
|
||||
run: pnpm test:coverage
|
||||
# Thresholds configured in vite.config.ts — exits non-zero if coverage drops
|
||||
|
||||
- name: Rust coverage (≥85% lines)
|
||||
- name: Rust tests + coverage (≥85% lines)
|
||||
run: |
|
||||
cargo llvm-cov \
|
||||
--manifest-path src-tauri/Cargo.toml \
|
||||
@@ -167,3 +163,63 @@ jobs:
|
||||
|
||||
- name: Format check (Rust)
|
||||
run: cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
|
||||
|
||||
linux-build:
|
||||
name: Linux build verification
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Tauri Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Frontend build
|
||||
run: pnpm build
|
||||
|
||||
- name: Cargo check
|
||||
run: cargo check --manifest-path=src-tauri/Cargo.toml
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
|
||||
|
||||
376
.github/workflows/release-stable.yml
vendored
376
.github/workflows/release-stable.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 2: Build each architecture in parallel
|
||||
# Phase 2: Build release bundles in parallel
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
@@ -62,6 +62,8 @@ jobs:
|
||||
include:
|
||||
- arch: aarch64
|
||||
target: aarch64-apple-darwin
|
||||
- arch: x86_64
|
||||
target: x86_64-apple-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -227,6 +229,7 @@ jobs:
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
@@ -249,12 +252,248 @@ jobs:
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
|
||||
retention-days: 1
|
||||
|
||||
build-linux:
|
||||
name: Build (linux-x86_64)
|
||||
needs: version
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Tauri Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
curl \
|
||||
wget \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file \
|
||||
rpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached bundle artifacts
|
||||
run: |
|
||||
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Build Tauri app (Linux bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||
|
||||
- name: Validate Linux bundles
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
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 [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Linux bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-windows:
|
||||
name: Build (windows-x86_64)
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\.cargo\registry
|
||||
~\.cargo\git
|
||||
src-tauri\target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached Windows bundle artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
|
||||
|
||||
- name: Set version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.version.outputs.version }}"
|
||||
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
|
||||
$tauri.version = $version
|
||||
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
|
||||
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
|
||||
|
||||
- name: Validate Windows release env
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::$name is required to build signed Windows updater artifacts."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build Tauri app (Windows bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
|
||||
- name: Validate Windows bundles
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no installable NSIS or MSI bundle."
|
||||
exit 1
|
||||
fi
|
||||
for installer in "${installers[@]}"; do
|
||||
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
|
||||
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Windows bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 3: Publish GitHub Release
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: GitHub Release (stable)
|
||||
needs: [version, build]
|
||||
needs: [version, build, build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -266,6 +505,54 @@ jobs:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Normalize macOS release artifact names
|
||||
run: |
|
||||
normalize_macos_artifacts() {
|
||||
local arch="$1"
|
||||
local normalized_updater="$2"
|
||||
local normalized_dmg="$3"
|
||||
local updater_dir="updater-${arch}"
|
||||
local updater_file
|
||||
updater_file=$(find "$updater_dir" -maxdepth 1 -name "*.app.tar.gz" -print -quit)
|
||||
if [ -z "$updater_file" ]; then
|
||||
echo "::error::Missing macOS updater artifact in ${updater_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local sig_file="${updater_file}.sig"
|
||||
if [ ! -f "$sig_file" ]; then
|
||||
echo "::error::Missing macOS updater signature for ${updater_file}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local normalized_sig="${normalized_updater}.sig"
|
||||
if [ "$updater_file" != "$normalized_updater" ]; then
|
||||
mv "$updater_file" "$normalized_updater"
|
||||
fi
|
||||
if [ "$sig_file" != "$normalized_sig" ]; then
|
||||
mv "$sig_file" "$normalized_sig"
|
||||
fi
|
||||
|
||||
local dmg_dir="dmg-${arch}"
|
||||
local dmg_file
|
||||
dmg_file=$(find "$dmg_dir" -maxdepth 1 -name "*.dmg" -print -quit)
|
||||
if [ -z "$dmg_file" ]; then
|
||||
echo "::error::Missing macOS DMG artifact in ${dmg_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$dmg_file" != "$normalized_dmg" ]; then
|
||||
mv "$dmg_file" "$normalized_dmg"
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_macos_artifacts aarch64 \
|
||||
"updater-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.app.tar.gz" \
|
||||
"dmg-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.dmg"
|
||||
normalize_macos_artifacts x86_64 \
|
||||
"updater-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.app.tar.gz" \
|
||||
"dmg-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.dmg"
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
PREV_TAG=$(git tag --list 'stable-v*' --sort=-version:refname | grep -vx "${{ needs.version.outputs.tag }}" | head -n 1 || echo "")
|
||||
@@ -282,7 +569,7 @@ jobs:
|
||||
echo "---"
|
||||
echo "**Stable release — manually promoted from \`main\`**"
|
||||
echo ""
|
||||
echo "**Requires Apple Silicon (M1/M2/M3)**"
|
||||
echo "**Includes macOS (Apple Silicon and Intel), Windows x64, and Linux x64 bundles**"
|
||||
echo ""
|
||||
echo "*Built from \`$(git rev-parse --short ${{ needs.version.outputs.tag }})\` on $(date -u +%Y-%m-%d)*"
|
||||
} > release_notes.md
|
||||
@@ -295,9 +582,42 @@ jobs:
|
||||
REPO_NAME="${REPO#*/}"
|
||||
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename)
|
||||
find_required() {
|
||||
local patterns=("$@")
|
||||
for pattern in "${patterns[@]}"; do
|
||||
set -- $pattern
|
||||
if [ -e "$1" ]; then
|
||||
printf '%s\n' "$1"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "::error::Missing required artifact matching one of: ${patterns[*]}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig")
|
||||
ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}"
|
||||
ARM_SIG=$(cat "$ARM_SIG_FILE")
|
||||
ARM_TARBALL=$(basename "$ARM_UPDATER_FILE")
|
||||
ARM_DMG=$(basename "$(find_required "dmg-aarch64/*.dmg")")
|
||||
|
||||
INTEL_SIG_FILE=$(find_required "updater-x86_64/*.app.tar.gz.sig")
|
||||
INTEL_UPDATER_FILE="${INTEL_SIG_FILE%.sig}"
|
||||
INTEL_SIG=$(cat "$INTEL_SIG_FILE")
|
||||
INTEL_TARBALL=$(basename "$INTEL_UPDATER_FILE")
|
||||
INTEL_DMG=$(basename "$(find_required "dmg-x86_64/*.dmg")")
|
||||
|
||||
LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*/*.deb.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.deb.sig")
|
||||
LINUX_UPDATER_FILE="${LINUX_SIG_FILE%.sig}"
|
||||
LINUX_SIG=$(cat "$LINUX_SIG_FILE")
|
||||
LINUX_UPDATER=$(basename "$LINUX_UPDATER_FILE")
|
||||
LINUX_DOWNLOAD=$(basename "$(find_required "linux-x86_64-bundles/*/*.AppImage" "linux-x86_64-bundles/*/*.deb" "linux-x86_64-bundles/*/*.AppImage.tar.gz" "linux-x86_64-bundles/*.AppImage" "linux-x86_64-bundles/*.deb" "linux-x86_64-bundles/*.AppImage.tar.gz")")
|
||||
|
||||
WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*/*-setup.exe.sig" "windows-x86_64-bundles/*/*.msi.sig" "windows-x86_64-bundles/*/*.nsis.zip.sig" "windows-x86_64-bundles/*/*.msi.zip.sig" "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig")
|
||||
WINDOWS_UPDATER_FILE="${WINDOWS_SIG_FILE%.sig}"
|
||||
WINDOWS_SIG=$(cat "$WINDOWS_SIG_FILE")
|
||||
WINDOWS_UPDATER=$(basename "$WINDOWS_UPDATER_FILE")
|
||||
WINDOWS_DOWNLOAD=$(basename "$(find_required "windows-x86_64-bundles/*/*-setup.exe" "windows-x86_64-bundles/*/*.msi" "windows-x86_64-bundles/*/*.nsis.zip" "windows-x86_64-bundles/*/*.msi.zip" "windows-x86_64-bundles/*-setup.exe" "windows-x86_64-bundles/*.msi" "windows-x86_64-bundles/*.nsis.zip" "windows-x86_64-bundles/*.msi.zip")")
|
||||
|
||||
cat > stable-latest.json << EOF
|
||||
{
|
||||
@@ -309,6 +629,21 @@ jobs:
|
||||
"signature": "${ARM_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}",
|
||||
"dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}"
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
"signature": "${INTEL_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_TARBALL}",
|
||||
"dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_DMG}"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"signature": "${LINUX_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_DOWNLOAD}"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": "${WINDOWS_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_DOWNLOAD}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,6 +662,35 @@ jobs:
|
||||
dmg-aarch64/*.dmg
|
||||
updater-aarch64/*.app.tar.gz
|
||||
updater-aarch64/*.app.tar.gz.sig
|
||||
dmg-x86_64/*.dmg
|
||||
updater-x86_64/*.app.tar.gz
|
||||
updater-x86_64/*.app.tar.gz.sig
|
||||
linux-x86_64-bundles/*.deb
|
||||
linux-x86_64-bundles/*.deb.sig
|
||||
linux-x86_64-bundles/*.rpm
|
||||
linux-x86_64-bundles/*.AppImage
|
||||
linux-x86_64-bundles/*.AppImage.sig
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
||||
linux-x86_64-bundles/*/*.deb
|
||||
linux-x86_64-bundles/*/*.deb.sig
|
||||
linux-x86_64-bundles/*/*.rpm
|
||||
linux-x86_64-bundles/*/*.AppImage
|
||||
linux-x86_64-bundles/*/*.AppImage.sig
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz.sig
|
||||
windows-x86_64-bundles/*.exe
|
||||
windows-x86_64-bundles/*.exe.sig
|
||||
windows-x86_64-bundles/*.msi
|
||||
windows-x86_64-bundles/*.msi.sig
|
||||
windows-x86_64-bundles/*.zip
|
||||
windows-x86_64-bundles/*.zip.sig
|
||||
windows-x86_64-bundles/*/*.exe
|
||||
windows-x86_64-bundles/*/*.exe.sig
|
||||
windows-x86_64-bundles/*/*.msi
|
||||
windows-x86_64-bundles/*/*.msi.sig
|
||||
windows-x86_64-bundles/*/*.zip
|
||||
windows-x86_64-bundles/*/*.zip.sig
|
||||
stable-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
353
.github/workflows/release.yml
vendored
353
.github/workflows/release.yml
vendored
@@ -121,6 +121,8 @@ jobs:
|
||||
include:
|
||||
- arch: aarch64
|
||||
target: aarch64-apple-darwin
|
||||
- arch: x86_64
|
||||
target: x86_64-apple-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -286,6 +288,7 @@ jobs:
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
@@ -303,13 +306,248 @@ jobs:
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
|
||||
retention-days: 1
|
||||
|
||||
build-linux:
|
||||
name: Build (linux-x86_64)
|
||||
needs: version
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Tauri Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
curl \
|
||||
wget \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file \
|
||||
rpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached bundle artifacts
|
||||
run: |
|
||||
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Build Tauri app (Linux bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||
|
||||
- name: Validate Linux bundles
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
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 [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Linux bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-windows:
|
||||
name: Build (windows-x86_64)
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\.cargo\registry
|
||||
~\.cargo\git
|
||||
src-tauri\target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached Windows bundle artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
|
||||
|
||||
- name: Set version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.version.outputs.version }}"
|
||||
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
|
||||
$tauri.version = $version
|
||||
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
|
||||
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
|
||||
|
||||
- name: Validate Windows release env
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::$name is required to build signed Windows updater artifacts."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build Tauri app (Windows bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
|
||||
- name: Validate Windows bundles
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no installable NSIS or MSI bundle."
|
||||
exit 1
|
||||
fi
|
||||
for installer in "${installers[@]}"; do
|
||||
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
|
||||
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Windows bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 3: Publish GitHub Release
|
||||
# No lipo/re-signing — use the per-arch artifacts directly
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: GitHub Release (alpha)
|
||||
needs: [version, build]
|
||||
needs: [version, build, build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -321,6 +559,37 @@ jobs:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Normalize macOS updater artifact names
|
||||
run: |
|
||||
normalize_updater() {
|
||||
local arch="$1"
|
||||
local normalized_updater="$2"
|
||||
local artifact_dir="updater-${arch}"
|
||||
local updater_file
|
||||
updater_file=$(find "$artifact_dir" -maxdepth 1 -name "*.app.tar.gz" -print -quit)
|
||||
if [ -z "$updater_file" ]; then
|
||||
echo "::error::Missing macOS updater artifact in ${artifact_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local sig_file="${updater_file}.sig"
|
||||
if [ ! -f "$sig_file" ]; then
|
||||
echo "::error::Missing macOS updater signature for ${updater_file}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local normalized_sig="${normalized_updater}.sig"
|
||||
if [ "$updater_file" != "$normalized_updater" ]; then
|
||||
mv "$updater_file" "$normalized_updater"
|
||||
fi
|
||||
if [ "$sig_file" != "$normalized_sig" ]; then
|
||||
mv "$sig_file" "$normalized_sig"
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_updater aarch64 "updater-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.app.tar.gz"
|
||||
normalize_updater x86_64 "updater-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.app.tar.gz"
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
PREV_TAG=$(python3 <<'PY'
|
||||
@@ -357,7 +626,7 @@ jobs:
|
||||
echo "---"
|
||||
echo "**Alpha build — updated on every push to \`main\`**"
|
||||
echo ""
|
||||
echo "**Requires Apple Silicon (M1/M2/M3)**"
|
||||
echo "**Includes macOS (Apple Silicon and Intel), Linux x64, and Windows x64 bundles**"
|
||||
echo ""
|
||||
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
|
||||
} > release_notes.md
|
||||
@@ -370,8 +639,38 @@ jobs:
|
||||
REPO_NAME="${REPO#*/}"
|
||||
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
find_required() {
|
||||
for pattern in "$@"; do
|
||||
set -- $pattern
|
||||
if [ -e "$1" ]; then
|
||||
printf '%s\n' "$1"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig")
|
||||
ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}"
|
||||
ARM_SIG=$(cat "$ARM_SIG_FILE")
|
||||
ARM_UPDATER=$(basename "$ARM_UPDATER_FILE")
|
||||
|
||||
INTEL_SIG_FILE=$(find_required "updater-x86_64/*.app.tar.gz.sig")
|
||||
INTEL_UPDATER_FILE="${INTEL_SIG_FILE%.sig}"
|
||||
INTEL_SIG=$(cat "$INTEL_SIG_FILE")
|
||||
INTEL_UPDATER=$(basename "$INTEL_UPDATER_FILE")
|
||||
|
||||
LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*/*.deb.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.deb.sig")
|
||||
LINUX_UPDATER_FILE="${LINUX_SIG_FILE%.sig}"
|
||||
LINUX_SIG=$(cat "$LINUX_SIG_FILE")
|
||||
LINUX_UPDATER=$(basename "$LINUX_UPDATER_FILE")
|
||||
LINUX_DOWNLOAD=$(basename "$(find_required "linux-x86_64-bundles/*/*.AppImage" "linux-x86_64-bundles/*/*.deb" "linux-x86_64-bundles/*/*.AppImage.tar.gz" "linux-x86_64-bundles/*.AppImage" "linux-x86_64-bundles/*.deb" "linux-x86_64-bundles/*.AppImage.tar.gz")")
|
||||
|
||||
WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*/*-setup.exe.sig" "windows-x86_64-bundles/*/*.msi.sig" "windows-x86_64-bundles/*/*.nsis.zip.sig" "windows-x86_64-bundles/*/*.msi.zip.sig" "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig")
|
||||
WINDOWS_UPDATER_FILE="${WINDOWS_SIG_FILE%.sig}"
|
||||
WINDOWS_SIG=$(cat "$WINDOWS_SIG_FILE")
|
||||
WINDOWS_UPDATER=$(basename "$WINDOWS_UPDATER_FILE")
|
||||
WINDOWS_DOWNLOAD=$(basename "$(find_required "windows-x86_64-bundles/*/*-setup.exe" "windows-x86_64-bundles/*/*.msi" "windows-x86_64-bundles/*/*.nsis.zip" "windows-x86_64-bundles/*/*.msi.zip" "windows-x86_64-bundles/*-setup.exe" "windows-x86_64-bundles/*.msi" "windows-x86_64-bundles/*.nsis.zip" "windows-x86_64-bundles/*.msi.zip")")
|
||||
|
||||
cat > alpha-latest.json << EOF
|
||||
{
|
||||
@@ -381,7 +680,23 @@ jobs:
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
"signature": "${ARM_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}"
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}"
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
"signature": "${INTEL_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_UPDATER}"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"signature": "${LINUX_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_DOWNLOAD}"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": "${WINDOWS_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_DOWNLOAD}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,6 +714,34 @@ jobs:
|
||||
files: |
|
||||
updater-aarch64/*.app.tar.gz
|
||||
updater-aarch64/*.app.tar.gz.sig
|
||||
updater-x86_64/*.app.tar.gz
|
||||
updater-x86_64/*.app.tar.gz.sig
|
||||
linux-x86_64-bundles/*.deb
|
||||
linux-x86_64-bundles/*.deb.sig
|
||||
linux-x86_64-bundles/*.rpm
|
||||
linux-x86_64-bundles/*.AppImage
|
||||
linux-x86_64-bundles/*.AppImage.sig
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
||||
linux-x86_64-bundles/*/*.deb
|
||||
linux-x86_64-bundles/*/*.deb.sig
|
||||
linux-x86_64-bundles/*/*.rpm
|
||||
linux-x86_64-bundles/*/*.AppImage
|
||||
linux-x86_64-bundles/*/*.AppImage.sig
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz.sig
|
||||
windows-x86_64-bundles/*.exe
|
||||
windows-x86_64-bundles/*.exe.sig
|
||||
windows-x86_64-bundles/*.msi
|
||||
windows-x86_64-bundles/*.msi.sig
|
||||
windows-x86_64-bundles/*.zip
|
||||
windows-x86_64-bundles/*.zip.sig
|
||||
windows-x86_64-bundles/*/*.exe
|
||||
windows-x86_64-bundles/*/*.exe.sig
|
||||
windows-x86_64-bundles/*/*.msi
|
||||
windows-x86_64-bundles/*/*.msi.sig
|
||||
windows-x86_64-bundles/*/*.zip
|
||||
windows-x86_64-bundles/*/*.zip.sig
|
||||
alpha-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -70,5 +70,6 @@ CODE-HEALTH-REPORT.md
|
||||
*.key.pub
|
||||
|
||||
# Local environment variables (never commit)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
31
AGENTS.md
31
AGENTS.md
@@ -22,7 +22,7 @@ Run `/laputa-next-task` — fetches next task (To Rework first, then Open), move
|
||||
- Work on `main` branch — **no branches, no PRs, ever**. Pre-commit and pre-push block work from any other branch.
|
||||
- Commit every 20–30 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
|
||||
- **⛔ NEVER use --no-verify**
|
||||
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode
|
||||
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode. You don't need Pencil to use it – you can open it as a JSON file.
|
||||
|
||||
### 1c. When done
|
||||
|
||||
@@ -47,16 +47,14 @@ bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
|
||||
|
||||
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
|
||||
|
||||
After both phases pass, add a **completion comment** to the Todoist task before running `/laputa-done`. The comment must include:
|
||||
- What was implemented (1–2 lines)
|
||||
After both phases pass, add a **completion comment** to the Todoist task. The comment must include:
|
||||
- What was implemented (a few lines covering logic and UX/UI)
|
||||
- QA: what was tested and how (Playwright / native screenshot / osascript)
|
||||
- Refactoring: any files refactored to meet the CodeScene gate (or "none needed")
|
||||
- ADRs: any new/updated ADRs (or "none")
|
||||
- Docs: any updated docs (ARCHITECTURE.md, ABSTRACTIONS.md, etc.) (or "none")
|
||||
- Code health: final Hotspot and Average scores after push
|
||||
|
||||
Then run `/laputa-done <task_id>` → moves to In Review, notifies Brian, self-dispatches next task.
|
||||
|
||||
---
|
||||
|
||||
## 2. Development Process
|
||||
@@ -73,6 +71,22 @@ Red → Green → Refactor → Commit. One cycle per commit. For bugs: write fai
|
||||
|
||||
**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests first. Prefer E2E over unit tests for user flows.
|
||||
|
||||
### Localization (mandatory for UI copy)
|
||||
|
||||
All user-facing UI labels/copy must live in `src/lib/locales/en.json` and be translated into every target listed in `lara.yaml`. When adding or changing interface copy:
|
||||
|
||||
```bash
|
||||
pnpm l10n:translate
|
||||
```
|
||||
|
||||
Use `pnpm l10n:translate:force` only when intentionally regenerating existing translations. Commit `src/lib/locales/*.json`, `lara.yaml`/`lara.lock` changes if produced, and verify placeholders/product names stayed intact.
|
||||
|
||||
### Product analytics (mandatory for meaningful features)
|
||||
|
||||
New features should almost always emit a PostHog event so we can see whether users actually discover and use them. Skip instrumentation only for very small changes where a dedicated event would create noise. Use clear, stable event names, avoid PII or note content, and include only safe metadata that helps evaluate adoption and failures.
|
||||
|
||||
When adding or changing a meaningful user-facing feature, include the event name(s) in the Todoist completion comment alongside QA, docs, and code health. If intentionally not instrumenting a feature, explain why in the completion comment.
|
||||
|
||||
### Code health (mandatory)
|
||||
|
||||
Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Both gates block commit/push. Thresholds are a **ratchet** — only go up. When pre-push sees improved remote scores, it updates `.codescene-thresholds`, stages it, and stops so you can commit the new floor with normal verified hooks before pushing again. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`.
|
||||
@@ -122,10 +136,6 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
|
||||
- **Delete all test notes from disk** when done — do not leave untitled or temporary notes on the filesystem. Run `cd ~/Laputa && git checkout -- . && git clean -fd` to restore the vault to its last committed state.
|
||||
- **Rationale:** test notes pollute the local vault over time, making it a collection of nonsensical untitled files. The vault must stay clean on disk, not just on the remote.
|
||||
|
||||
### UI design
|
||||
|
||||
Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task; on completion merge into `ui-design.pen` and delete it.
|
||||
|
||||
### UI components — mandatory rules
|
||||
|
||||
**Always use shadcn/ui components.** Never use raw HTML form elements (`<input>`, `<select>`, `<button>`, native `<input type="date">`, etc.) for user-facing UI. Every interactive element must use the shadcn/ui equivalent:
|
||||
@@ -155,10 +165,11 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
|
||||
- Tauri menu accelerators: `MenuItemBuilder::new(label).accelerator("CmdOrCtrl+1")`
|
||||
- `app.set_menu()` replaces the ENTIRE menu bar — include all submenus
|
||||
- `mock-tauri.ts` silently swallows Tauri calls — not a substitute for native testing
|
||||
|
||||
### QA scripts
|
||||
|
||||
```bash
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh Tolaria
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/out.png
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/shortcut.sh "command" "s"
|
||||
```
|
||||
|
||||
49
CONTRIBUTING.md
Normal file
49
CONTRIBUTING.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Contributing to Tolaria
|
||||
|
||||
Thanks for being here! Tolaria is still early, and every bug report, idea, and contribution genuinely helps shape the app.
|
||||
|
||||
## 🗳️ Where to share what
|
||||
|
||||
To keep things clean:
|
||||
|
||||
- 🐛 Bugs → GitHub Issues
|
||||
- 💡 Feature requests / ideas → Canny • <https://tolaria.canny.io/>
|
||||
|
||||
If you have a feature idea, please check Canny first and upvote it if it already exists.
|
||||
|
||||
## 📥 Pull requests are welcome
|
||||
|
||||
PRs are very welcome.
|
||||
|
||||
A few things to keep in mind before opening one:
|
||||
|
||||
- Bug fixes are always great
|
||||
- Small improvements are great too
|
||||
- For bigger features, please check Canny first before building
|
||||
- Try to avoid things that are already marked **in progress**
|
||||
- Requests marked **planned** are usually great contribution targets
|
||||
- Keep PRs small, focused, and easy to review
|
||||
- Include a short explanation of the problem and your solution
|
||||
- Follow the dev process described in Tolaria’s `AGENTS.md` (tests, code health, etc.)
|
||||
- Avoid bundling unrelated refactors into the same PR
|
||||
|
||||
If you want to contribute a feature, the best place to start is here: <https://tolaria.canny.io/>
|
||||
|
||||
## 📋 What makes a good bug report
|
||||
|
||||
If you open a bug report on GitHub, it really helps to include:
|
||||
|
||||
- your Tolaria version
|
||||
- your OS version
|
||||
- steps to reproduce
|
||||
- what you expected to happen
|
||||
- what actually happened
|
||||
- screenshots or screen recordings if useful
|
||||
|
||||
The clearer the report, the easier it is for us to reproduce and fix it.
|
||||
|
||||
## 🙏 Thank you
|
||||
|
||||
Tolaria is getting better because people care enough to try it, report what’s broken, suggest what’s missing, and contribute improvements.
|
||||
|
||||
That means a lot. Thanks for helping build it.
|
||||
45
README.md
45
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
# 💧 Tolaria
|
||||
|
||||
Tolaria is a desktop app for Mac for managing **markdown knowledge bases**. People use it for a variety of use cases:
|
||||
Tolaria is a desktop app for macOS, Windows, and Linux for managing **markdown knowledge bases**. People use it for a variety of use cases:
|
||||
|
||||
* Operate second brains and personal knowledge
|
||||
* Organize company docs as context for AI
|
||||
@@ -27,13 +27,25 @@ You can find some Loom walkthroughs below — they are short and to the point:
|
||||
- 🔬 **Open source** — Tolaria is free and open source. I built this for [myself](https://x.com/lucaronin) and for sharing it with others.
|
||||
- 📋 **Standards-based** — Notes are markdown files with YAML frontmatter. No proprietary formats, no locked-in data. Everything works with standard tools if you decide to move away from Tolaria.
|
||||
- 🔍 **Types as lenses, not schemas** — Types in Tolaria are navigation aids, not enforcement mechanisms. There's no required fields, no validation, just helpful categories for finding notes.
|
||||
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code and Codex CLI (for now), but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
|
||||
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code, Codex CLI, and Gemini CLI setup paths, but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
|
||||
- ⌨️ **Keyboard-first** — Tolaria is designed for power-users who want to use keyboard as much as possible. A lot of how we designed the Editor and the Command Palette is based on this.
|
||||
- 💪 **Built from real use** — Tolaria was created for manage my personal vault of 10,000+ notes, and I use it every day. Every feature exists because it solved a real problem.
|
||||
|
||||
## Getting started
|
||||
## Installation
|
||||
|
||||
Download the [latest release here](https://github.com/refactoringhq/tolaria/releases/latest/download/Tolaria.app.tar.gz).
|
||||
### Homebrew
|
||||
|
||||
Install via Homebrew on macOS:
|
||||
|
||||
```batch
|
||||
brew install --cask tolaria
|
||||
```
|
||||
|
||||
### Download from releases
|
||||
|
||||
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux.
|
||||
|
||||
## Getting started
|
||||
|
||||
When you open Tolaria for the first time you get the chance of cloning the [getting started vault](https://github.com/refactoringhq/tolaria-getting-started) — which gives you a walkthrough of the whole app.
|
||||
|
||||
@@ -46,7 +58,30 @@ Tolaria is open source and built with Tauri, React, and TypeScript. If you want
|
||||
- Node.js 20+
|
||||
- pnpm 8+
|
||||
- Rust stable
|
||||
- macOS for development
|
||||
- macOS or Linux for development
|
||||
|
||||
#### Linux system dependencies
|
||||
|
||||
Tauri 2 on Linux requires WebKit2GTK 4.1 and GTK 3:
|
||||
|
||||
- Arch / Manjaro:
|
||||
```bash
|
||||
sudo pacman -S --needed webkit2gtk-4.1 base-devel curl wget file openssl \
|
||||
appmenu-gtk-module libappindicator-gtk3 librsvg
|
||||
```
|
||||
- Debian / Ubuntu (22.04+):
|
||||
```bash
|
||||
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
|
||||
libsoup-3.0-dev patchelf
|
||||
```
|
||||
- Fedora 38+:
|
||||
```bash
|
||||
sudo dnf install webkit2gtk4.1-devel openssl-devel curl wget file \
|
||||
libappindicator-gtk3-devel librsvg2-devel
|
||||
```
|
||||
|
||||
The bundled MCP server still spawns the system `node` binary at runtime on Linux, so install Node from your distro package manager if you want the external AI tooling flow.
|
||||
|
||||
### Quick start
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ _icon: shapes # icon assigned to a type
|
||||
_color: blue # color assigned to a type
|
||||
_order: 10 # sort order in the sidebar
|
||||
_sidebar_label: Projects # override label in sidebar
|
||||
_width: wide # rich-editor width override for this note
|
||||
```
|
||||
|
||||
**This convention is universal** — apply it to all future system-level frontmatter fields. When a new feature needs to store configuration in a note's frontmatter (especially in Type notes), use `_field_name` to keep it hidden from normal user-facing surfaces while still stored on-disk as plain text.
|
||||
@@ -57,6 +58,19 @@ The frontmatter parser (Rust: `vault/mod.rs`, TS: `utils/frontmatter.ts`) must f
|
||||
|
||||
All data lives in markdown files with YAML frontmatter. There is no database — the filesystem is the source of truth.
|
||||
|
||||
### Vault Git Capability
|
||||
|
||||
Git is a per-vault capability, not a prerequisite for the document model. A vault can be:
|
||||
|
||||
| State | Meaning | UI behavior |
|
||||
|---|---|---|
|
||||
| Git-backed | The vault path contains a Git repository | History, changes, commits, sync, conflict resolution, remotes, AutoGit, and auto-sync are available according to remote/config state |
|
||||
| Non-git | The vault path is a plain folder | Markdown scanning, editing, search, and navigation work; Git-dependent status-bar controls and command-palette entries are replaced by `Git disabled` + `Initialize Git for Current Vault` |
|
||||
|
||||
Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. Features that depend on Git must check this capability instead of assuming every vault has `.git`.
|
||||
|
||||
Git initialization is intentionally scoped to dedicated vault folders. When the current non-git folder looks like a broad personal root such as Documents, Desktop, or Downloads and does not already carry Tolaria-managed vault markers, `init_git_repo` refuses to run Git and asks the user to select or create a dedicated subfolder instead.
|
||||
|
||||
### VaultEntry
|
||||
|
||||
The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`).
|
||||
@@ -74,6 +88,7 @@ classDiagram
|
||||
+Record~string,string[]~ relationships
|
||||
+String[] outgoingLinks
|
||||
+String? status
|
||||
+String? noteWidth
|
||||
+Number? modifiedAt
|
||||
+Number? createdAt
|
||||
+Number wordCount
|
||||
@@ -122,6 +137,7 @@ interface VaultEntry {
|
||||
relationships: Record<string, string[]> // All frontmatter fields containing wikilinks
|
||||
outgoingLinks: string[] // All [[wikilinks]] found in note body
|
||||
status: string | null // Active, Done, Paused, Archived, Dropped
|
||||
noteWidth?: 'normal' | 'wide' | null // Rich-editor width mode from `_width`
|
||||
modifiedAt: number | null // Unix timestamp (seconds)
|
||||
// Note: owner and cadence are now in the generic `properties` map
|
||||
createdAt: number | null // Unix timestamp (seconds)
|
||||
@@ -132,9 +148,28 @@ interface VaultEntry {
|
||||
trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent)
|
||||
trashedAt: number | null // Kept for backward compatibility (Trash system removed)
|
||||
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
|
||||
fileKind?: 'markdown' | 'text' | 'binary' // Controls editor/raw/preview behavior
|
||||
}
|
||||
```
|
||||
|
||||
### File kinds and binary previews
|
||||
|
||||
`VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained:
|
||||
|
||||
| `fileKind` | Source files | UI behavior |
|
||||
|---|---|---|
|
||||
| `markdown` or absent | `.md`, `.markdown` | Full Tolaria note model: frontmatter, BlockNote, raw editor, relationships, title sync |
|
||||
| `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics |
|
||||
| `binary` | Images, PDFs, archives, other non-text files | Stays a normal vault file; previewable images and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
|
||||
|
||||
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>` and supported PDFs render through the webview's PDF object renderer, both backed by Tauri asset URLs. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
|
||||
|
||||
### Note Content Freshness
|
||||
|
||||
The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. Before showing cached markdown or editor-ready blocks, `useTabManagement` validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery.
|
||||
|
||||
Prepared BlockNote blocks in `useEditorTabSwap` are keyed by path plus source content. They can be built ahead of time from prefetched markdown, but they are reused only when the validated raw content for that path is identical to the source content that produced the blocks.
|
||||
|
||||
### Entity Types (isA / type)
|
||||
|
||||
Entity type is stored in the `type:` frontmatter field (e.g. `type: Quarter`). The legacy field name `Is A:` is still accepted as an alias for backwards compatibility but new notes use `type:`. The `VaultEntry.isA` property in TypeScript/Rust holds the resolved value.
|
||||
@@ -149,17 +184,19 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
|
||||
├── some-topic.md ← type: Topic
|
||||
├── AGENTS.md ← canonical Tolaria AI guidance
|
||||
├── CLAUDE.md ← compatibility shim pointing at AGENTS.md
|
||||
├── GEMINI.md ← optional Gemini CLI shim pointing at AGENTS.md
|
||||
├── project.md ← type: Type (definition document)
|
||||
├── person.md ← type: Type (definition document)
|
||||
├── ...
|
||||
└── type/ ← type definition documents
|
||||
```
|
||||
|
||||
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. Moving a note into a user folder is a separate filesystem concern: the folder path changes, but the note keeps the same filename and `type:` value. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
|
||||
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. Moving a note into a user folder is a separate filesystem concern: the folder path changes, but the note keeps the same filename and `type:` value. Legacy `type/` and `types/` folders are still scanned like other non-hidden vault folders, so existing type documents in those folders continue to work, but new type documents created by Tolaria are written at the vault root. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
|
||||
|
||||
A `flatten_vault` migration command is available to move existing notes from type-based subfolders to the vault root.
|
||||
|
||||
### Types as Files
|
||||
|
||||
Each entity type can have a corresponding **type document** in the `type/` folder (e.g., `type/project.md`, `type/person.md`). Type documents:
|
||||
Each entity type can have a corresponding **type document**: any markdown note with `type: Type` in its frontmatter. Tolaria creates new type documents at the vault root (e.g., `project.md`, `person.md`) and still reads existing type documents from subfolders. Type documents:
|
||||
|
||||
- Have `type: Type` in their frontmatter (`Is A: Type` also accepted as legacy alias)
|
||||
- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility
|
||||
@@ -179,7 +216,7 @@ Each entity type can have a corresponding **type document** in the `type/` folde
|
||||
| `view` | string | Default view mode: "all", "editor-list", "editor-only" |
|
||||
| `visible` | bool | Whether type appears in sidebar (default: true) |
|
||||
|
||||
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[type/project]]`. This makes the type navigable from the Inspector panel.
|
||||
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[project]]`. This makes the type navigable from the Inspector panel while keeping location as an implementation detail.
|
||||
|
||||
**UI behavior**:
|
||||
- Clicking a section group header pins the type document at the top of the NoteList if it exists
|
||||
@@ -240,6 +277,9 @@ Tolaria separates **display title** from the file identifier:
|
||||
- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem.
|
||||
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
|
||||
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions stage crash-safe note renames through a hidden `.tolaria-rename-txn/` transaction directory, recover unfinished renames on the next vault scan, update wikilinks across the vault, and surface any failed backlink rewrites instead of silently reporting partial success. The editor body remains the title editing surface.
|
||||
- **Unicode-aware note stems** (`src/utils/noteSlug.ts`, `vault/rename.rs`): frontend and backend slugging preserve Unicode letters/digits in note filenames, untitled-rename detection, and fallback wikilink targets while still collapsing symbol-only titles to `untitled`.
|
||||
- **Portable filename validation** (`vault/filename_rules.rs`): note filenames, folder names, and custom view filenames all reject Windows-reserved device names, invalid characters, and trailing dot/space suffixes so a vault created on macOS/Linux still clones and syncs cleanly on Windows.
|
||||
- **Recoverable save failures** (`useEditorSave`, `vault/file.rs`): invalid platform path syntax is reported as a clear retryable save error, while the editor keeps the unsaved buffer intact for another attempt.
|
||||
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
|
||||
|
||||
### Title Surface (UI)
|
||||
@@ -268,12 +308,18 @@ type SidebarSelection =
|
||||
|
||||
`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight.
|
||||
|
||||
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated.
|
||||
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
|
||||
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
|
||||
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
|
||||
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
|
||||
- Folder deletion clears pending rename state, confirms destructive intent, drops affected folder-scoped tabs, reloads vault data, and resets folder selection if the deleted subtree owned the current selection.
|
||||
|
||||
### Saved Views
|
||||
|
||||
Saved Views live as YAML files under `views/`. Their definition includes user-visible fields (`name`, `icon`, `color`), note-list preferences (`sort`, `listPropertiesDisplay`), filters, and an optional top-level `order` number. The `order` value is stored directly in the YAML document, not in Markdown frontmatter, and lower values render earlier in every saved-View list. Views without an explicit order sort after ordered views by filename for stable fallback behavior.
|
||||
|
||||
The renderer uses `viewOrdering` helpers to convert drag or command-palette move intent into dense order updates before saving each affected view file through `save_view_cmd`. The sidebar keeps the saved View row visually unchanged: pointer users drag the row itself, and keyboard users use the command palette actions for the currently selected saved View.
|
||||
|
||||
### Neighborhood Mode
|
||||
|
||||
`SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing.
|
||||
@@ -294,24 +340,29 @@ type SidebarSelection =
|
||||
`vault::scan_vault(path)` in `src-tauri/src/vault/mod.rs`:
|
||||
|
||||
1. Validates the path exists and is a directory
|
||||
2. Scans root-level `.md` files (non-recursive)
|
||||
3. Recursively scans protected folders: `type/`, legacy `config/`, `attachments/`
|
||||
4. Files in non-protected subfolders are **not indexed** (flat vault enforcement)
|
||||
5. For each `.md` file, calls `parse_md_file()`:
|
||||
2. Recursively scans non-hidden files while skipping hidden directories such as `.git/`
|
||||
3. For each `.md` file, calls `parse_md_file()`:
|
||||
- Reads content with `fs::read_to_string()`
|
||||
- Parses frontmatter with `gray_matter::Matter::<YAML>`
|
||||
- Extracts title from first `#` heading
|
||||
- Reads entity type from `type:` frontmatter field (`Is A:` accepted as legacy alias); type is never inferred from folder
|
||||
- Parses dates as ISO 8601 to Unix timestamps
|
||||
- Extracts relationships, outgoing links, custom properties, word count, snippet
|
||||
4. For recognized non-markdown text and binary files, emits a minimal `VaultEntry` with `fileKind`
|
||||
5. Sorts by `modified_at` descending
|
||||
6. Skips unparseable files with a warning log
|
||||
|
||||
The folder tree hides only the dedicated `type/` directory, since note types already have their own sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders.
|
||||
6. Sorts by `modified_at` descending
|
||||
7. Skips unparseable files with a warning log
|
||||
The folder tree hides the legacy `type/` directory, since those type documents already appear through the Types sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders.
|
||||
|
||||
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, so negated and specific `.gitignore` patterns follow Git semantics as closely as the app can reasonably support.
|
||||
|
||||
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
|
||||
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, and folder mutations cannot step outside the active vault.
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
|
||||
|
||||
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. None of those actions mutate vault contents or bypass the backend write boundary.
|
||||
|
||||
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
|
||||
|
||||
### Vault Caching
|
||||
|
||||
@@ -415,6 +466,10 @@ interface PulseCommit {
|
||||
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
|
||||
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
|
||||
|
||||
### External Vault Refresh
|
||||
|
||||
External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note from disk, and closes the active tab if the file disappeared. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves.
|
||||
|
||||
`useGitRemoteStatus` is the commit-time companion to `useAutoSync`:
|
||||
- Re-checks `git_remote_status` when the Commit dialog opens and right before submit
|
||||
- Converts `hasRemote: false` into a local-only commit path
|
||||
@@ -471,6 +526,24 @@ Defined in `src/components/editorSchema.tsx` and styled in `src/components/Edito
|
||||
- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript while still supporting the packaged language aliases such as `ts` → `typescript`.
|
||||
- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep BlockNote's dark shell instead of inheriting the muted inline surface.
|
||||
|
||||
### Markdown Math
|
||||
|
||||
Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
|
||||
|
||||
- `$...$` becomes a `mathInline` schema node and line-owned `$$...$$` / multiline `$$` blocks become `mathBlock` nodes.
|
||||
- The rich editor renders both node types through KaTeX with `throwOnError: false`, so malformed formulas keep their source visible instead of breaking the note.
|
||||
- `serializeMathAwareBlocks()` converts math nodes back to Markdown delimiters before save, raw-mode entry, and editor-position snapshots.
|
||||
- Raw CodeMirror mode always shows the plain Markdown source, so imported technical notes stay editable outside Tolaria.
|
||||
|
||||
### Mermaid Diagrams
|
||||
|
||||
Defined in `src/utils/mermaidMarkdown.ts`, `src/components/MermaidDiagram.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
|
||||
|
||||
- Fenced `mermaid` blocks become `mermaidBlock` schema nodes before BlockNote sees the Markdown body.
|
||||
- Each `mermaidBlock` stores the original fenced Markdown plus the diagram body, so raw-mode entry and saves can restore the canonical source instead of serializing generated SVG.
|
||||
- The rich editor renders diagrams with the `mermaid` package and uses the original source as an inline fallback when rendering fails.
|
||||
- `serializeMermaidAwareBlocks()` wraps the math-aware serializer so math, wikilinks, and diagrams share the same Markdown-first save path.
|
||||
|
||||
### Formatting Surface Policy
|
||||
|
||||
Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`:
|
||||
@@ -480,29 +553,34 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
|
||||
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
|
||||
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
|
||||
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface.
|
||||
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
|
||||
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features.
|
||||
- `useNativePathDrop()` is the shared Tauri file/folder-drop abstraction for text inputs that need filesystem paths instead of attachment import. It consumes native window drag/drop events, gates them to the target element bounds or focused text selection, and lets AI composer / command-palette inputs insert formatted paths at the current cursor.
|
||||
|
||||
### Markdown-to-BlockNote Pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"]
|
||||
B --> C["preProcessWikilinks(body)\n[[target]] → ‹token›"]
|
||||
C --> D["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
|
||||
D --> E["injectWikilinks(blocks)\n‹token› → WikiLink node"]
|
||||
E --> F["editor.replaceBlocks()\n→ rendered editor"]
|
||||
B --> C["preProcessMermaidMarkdown(body)\nmermaid fence → token"]
|
||||
C --> D["preProcessWikilinks(body)\n[[target]] → ‹token›"]
|
||||
D --> E["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
|
||||
E --> F["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
|
||||
F --> G["injectWikilinks + injectMathInBlocks + injectMermaidInBlocks\n tokens → schema nodes"]
|
||||
G --> H["editor.replaceBlocks()\n→ rendered editor"]
|
||||
|
||||
style A fill:#f8f9fa,stroke:#6c757d,color:#000
|
||||
style F fill:#d4edda,stroke:#28a745,color:#000
|
||||
style H fill:#d4edda,stroke:#28a745,color:#000
|
||||
```
|
||||
|
||||
> Placeholder tokens use `\u2039` and `\u203A` to avoid colliding with markdown syntax.
|
||||
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math and Mermaid placeholder tokens use ASCII sentinels with URI-encoded payloads.
|
||||
|
||||
### BlockNote-to-Markdown Pipeline (Save)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["✏️ BlockNote blocks\n(editor state)"] --> B["blocksToMarkdownLossy()"]
|
||||
B --> C["postProcessWikilinks()\nWikiLink node → [[target]]"]
|
||||
B --> C["restoreWikilinks + serializeMermaidAwareBlocks()\nschema nodes → Markdown source"]
|
||||
C --> D["prepend frontmatter yaml"]
|
||||
D --> E["invoke('save_note_content')\n→ disk write"]
|
||||
|
||||
@@ -510,6 +588,8 @@ flowchart LR
|
||||
style E fill:#d4edda,stroke:#28a745,color:#000
|
||||
```
|
||||
|
||||
Rich-editor change events are coalesced before this serialization runs. `useEditorTabSwap` keeps the latest BlockNote state in the editor, schedules one Markdown serialization for a short idle window, and exposes an explicit flush hook for save, note switch, raw-mode entry, and destructive note actions. This keeps long notes from paying full-document Markdown serialization on every keystroke while preserving the disk-first save path.
|
||||
|
||||
### Wikilink Navigation
|
||||
|
||||
Two navigation mechanisms:
|
||||
@@ -524,6 +604,14 @@ Wikilink resolution (`resolveEntry` in `src/utils/wikilink.ts`) uses multi-pass
|
||||
Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command.
|
||||
While the user types, `useEditorSaveWithLinks` derives a transient `VaultEntry` patch from parseable frontmatter so the Inspector, relationship chips, and note-list-visible metadata stay in sync with the raw editor before the next vault reload. Temporarily invalid or half-typed frontmatter is ignored until it becomes parseable again, which avoids clobbering the last known good derived state.
|
||||
|
||||
Current-note find/replace is intentionally backed by raw CodeMirror mode. `Cmd+F`, "Find in Note", and "Replace in Note" switch the active Markdown/text note to raw mode, show the compact find bar above CodeMirror, and operate on the current note only. Plain text matching is case-insensitive by default, `Aa` toggles case sensitivity, `.*` toggles JavaScript-regex matching, and regex replacement supports capture groups through JavaScript replacement syntax.
|
||||
|
||||
### Rich Editor Width Modes
|
||||
|
||||
Rich Markdown editing supports `normal` and `wide` note widths. The effective mode is resolved in `App.tsx` from, in order, the current session's transient note-width cache, `VaultEntry.noteWidth` parsed from `_width`, and the installation-local `settings.note_width_mode` default. The breadcrumb toggle calls the same setter exposed through the command palette.
|
||||
|
||||
Per-note width is persisted as hidden `_width` frontmatter only when the note already has a valid or empty frontmatter block. Notes without frontmatter use the transient cache for the current session, so toggling width never creates frontmatter solely to store UI state. The width class is applied around `SingleEditorView` only; raw CodeMirror mode stays outside `.editor-content-wrapper` and remains full-width.
|
||||
|
||||
### Arrow Ligature Normalization
|
||||
|
||||
Typed ASCII arrow sequences are normalized consistently in both editor modes:
|
||||
@@ -534,10 +622,24 @@ Typed ASCII arrow sequences are normalized consistently in both editor modes:
|
||||
|
||||
## Styling
|
||||
|
||||
The app uses a single light theme — the vault-based theming system was removed (see [ADR-0013](adr/0013-remove-theming-system.md)). Styling is defined in two layers:
|
||||
The app uses internal light and dark themes owned by Tolaria (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md)). The previous vault-authored theming system remains removed; theme mode is an installation-local app preference.
|
||||
|
||||
1. **Global CSS variables** (`src/index.css`): App-wide colors via `:root`, bridged to Tailwind v4
|
||||
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states via `:root` / `[data-theme]`, bridged to Tailwind v4
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
|
||||
3. **Runtime theme bridge**: Applies `data-theme` and `.dark` for shadcn/ui, while CodeMirror and editor-specific consumers derive any non-CSS-variable values from the same semantic contract
|
||||
4. **Theme mode commands**: Command-palette actions for light and dark mode call the same `saveSettings` path as the Settings panel and persist only `settings.theme_mode`
|
||||
|
||||
## Localization
|
||||
|
||||
App UI strings are resolved through `src/lib/i18n.ts`, with flat JSON catalogs in `src/lib/locales/*.json` (see [ADR-0087](adr/0087-json-catalogs-and-lara-cli-localization.md)):
|
||||
|
||||
- `AppLocale`: canonical locale tags such as `'en'`, `'zh-CN'`, `'fr-FR'`, `'es-419'`
|
||||
- `UiLanguagePreference`: `'system' | AppLocale`; persisted settings serialize `system` as `null`
|
||||
- `resolveEffectiveLocale()`: maps an explicit preference or system/browser language list to the effective supported locale, including legacy aliases
|
||||
- `translate()` / `createTranslator()`: resolve keys with English fallback and simple `{name}` interpolation
|
||||
- `scripts/validate-locales.mjs`: asserts every checked-in locale catalog matches the English keyset and stays flat-string-only
|
||||
|
||||
`App.tsx` owns the effective locale and passes it to localized app chrome through props. Settings and command-palette language commands call back into `saveSettings`, so UI language changes update the current session without touching vault content or reopening the vault.
|
||||
|
||||
## Inspector Abstraction
|
||||
|
||||
@@ -559,7 +661,7 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
|
||||
### Search
|
||||
|
||||
Keyword-based search scans all vault `.md` files using `walkdir`:
|
||||
Keyword-based search scans all vault `.md` files using `walkdir` and applies the same Gitignored-content visibility filter as vault loading:
|
||||
|
||||
```typescript
|
||||
interface SearchResult {
|
||||
@@ -593,7 +695,8 @@ No indexing step required — search runs directly against the filesystem.
|
||||
|
||||
Per-vault settings stored locally and scoped by vault path:
|
||||
- Managed by `useVaultConfig` hook and `vaultConfigStore`
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes, Inbox note-list column overrides, explicit organization workflow toggle
|
||||
- Settings: zoom, view mode, editor mode, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`)
|
||||
- Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
### AI Guidance Files
|
||||
@@ -601,8 +704,9 @@ Per-vault settings stored locally and scoped by vault path:
|
||||
Tolaria tracks managed vault-level AI guidance separately from normal note content:
|
||||
- `AGENTS.md` is the canonical managed guidance file for Tolaria-aware coding agents
|
||||
- `CLAUDE.md` is a compatibility shim that points Claude Code back to `AGENTS.md`
|
||||
- `GEMINI.md` is an optional Gemini CLI compatibility shim that points Gemini back to `AGENTS.md`
|
||||
- `useVaultAiGuidanceStatus` reads `get_vault_ai_guidance_status` and normalizes the backend state into four UI cases: `managed`, `missing`, `broken`, and `custom`
|
||||
- `restore_vault_ai_guidance` repairs only Tolaria-managed files; user-authored custom `AGENTS.md` / `CLAUDE.md` files are surfaced as custom and left untouched
|
||||
- `restore_vault_ai_guidance` repairs only Tolaria-managed files and creates the optional Gemini shim on explicit request; user-authored custom `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` files are surfaced as custom and left untouched
|
||||
- The status bar AI badge and command palette consume that abstraction to expose restore actions only when the managed guidance is missing or broken
|
||||
|
||||
### Getting Started / Onboarding
|
||||
@@ -621,15 +725,16 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
|
||||
`useAiAgentsOnboarding(enabled)` adds a separate first-launch agent step:
|
||||
- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key)
|
||||
- Only shows after vault onboarding has already resolved to a ready state
|
||||
- Uses `get_ai_agents_status`, whose backend treats the app process path, login-shell path, and supported local/toolchain/app install locations, including 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
|
||||
|
||||
Tolaria delegates remote auth to the user's system git setup:
|
||||
- `CloneVaultModal` captures a remote URL and local destination
|
||||
- `clone_repo` shells out to system git for clone operations
|
||||
- `clone_git_repo` and `create_getting_started_vault` both run system git clone work in blocking Tokio tasks so clone UIs stay responsive
|
||||
- `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
|
||||
- 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
|
||||
|
||||
## Settings
|
||||
@@ -647,11 +752,15 @@ interface Settings {
|
||||
analytics_enabled: boolean | null
|
||||
anonymous_id: string | null
|
||||
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
|
||||
default_ai_agent: 'claude_code' | 'codex' | null
|
||||
theme_mode: 'light' | 'dark' | null
|
||||
ui_language: AppLocale | null
|
||||
note_width_mode: 'normal' | 'wide' | null
|
||||
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null
|
||||
hide_gitignored_files: boolean | null // null = default true
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette light/dark actions both update that same value. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The 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
|
||||
|
||||
@@ -663,9 +772,9 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
|
||||
- **`useTelemetry(settings, loaded)`** — Reactively initializes/tears down Sentry and PostHog based on settings. Called once in `App`.
|
||||
|
||||
### Libraries
|
||||
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/key from `VITE_SENTRY_DSN` / `VITE_POSTHOG_KEY` env vars.
|
||||
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/key from `VITE_SENTRY_DSN` and `VITE_POSTHOG_KEY`; `VITE_SENTRY_RELEASE` is treated as the build version and only becomes Sentry's `release` for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds tag `tolaria.build_version` and `tolaria.release_kind` without creating normal Sentry Releases entries.
|
||||
- **`src/main.tsx`** — React root error callbacks (`onCaughtError`, `onUncaughtError`, `onRecoverableError`) forward component-stack context to `Sentry.reactErrorHandler()` for debuggable production React errors.
|
||||
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes. `reinit_sentry()` for runtime toggle.
|
||||
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes; stable calendar `CARGO_PKG_VERSION` values become Sentry releases, while alpha/prerelease/internal versions are kept as diagnostic tags only. `reinit_sentry()` for runtime toggle.
|
||||
|
||||
### Tauri Commands
|
||||
- **`reinit_telemetry`** — Re-reads settings and toggles Rust Sentry on/off. Called from frontend when user changes crash reporting setting.
|
||||
@@ -691,6 +800,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
|
||||
- **`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` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`.
|
||||
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
|
||||
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.
|
||||
|
||||
@@ -24,6 +24,8 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
|
||||
| Pinned properties per type | API keys (OpenAI, Google) |
|
||||
| Sidebar label overrides | Auto-sync interval |
|
||||
| Property display order | Window size / position |
|
||||
| Per-note `_width` rich-editor width override | Default rich-editor note width |
|
||||
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
|
||||
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
|
||||
|
||||
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.tolaria.app/settings.json` or localStorage.
|
||||
@@ -31,7 +33,10 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
|
||||
Examples:
|
||||
- ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties)
|
||||
- ✅ Vault: `_icon: shapes` in a Type note (icon is part of the type's identity)
|
||||
- ✅ Vault: `_width: wide` in a note that already has frontmatter (per-note reading/editing preference)
|
||||
- ✅ App settings: `zoom: 1.3` (machine-specific preference)
|
||||
- ✅ App settings: `ui_language: "zh-CN"` (installation-specific UI language)
|
||||
- ✅ App settings: `note_width_mode: "wide"` (installation-specific default for notes without an override)
|
||||
|
||||
### No hardcoded exceptions
|
||||
|
||||
@@ -81,6 +86,23 @@ flowchart LR
|
||||
3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. The three functions in `useEntryActions` (`handleCustomizeType`, `handleRenameSection`, `handleToggleTypeVisibility`) follow this rule — disk write first, then state update.
|
||||
4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file.
|
||||
5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem.
|
||||
6. **Visibility filters are command-boundary concerns**: Gitignored-content visibility is applied after scanning/caching, before entries, folders, or search results reach React. The cache remains complete so toggling the setting can show ignored content again without rebuilding a different cache shape.
|
||||
|
||||
#### External Change Detection
|
||||
|
||||
The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and the clean active editor all refresh under the ADR-0071 unsaved-edit rules. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
|
||||
|
||||
#### Progressive Vault Loading
|
||||
|
||||
Vault opening is allowed to render the main app shell while the full entry scan is still in flight. `useVaultLoader` keeps `isLoading` true until entries are ready, but folders and saved views load independently so the sidebar can become useful before the note index completes. The status bar uses the vault activity badge during this initial indexing state, while command-palette and editor-shell interactions remain mounted instead of being hidden behind the full app skeleton. The full skeleton is reserved for app-level capability checks such as the initial Git-state probe.
|
||||
|
||||
Large-vault reproduction and keyboard QA steps live in [LARGE-VAULT-LOADING-QA.md](./LARGE-VAULT-LOADING-QA.md).
|
||||
|
||||
#### Note Opening Fast Path
|
||||
|
||||
Note opening uses a bounded in-memory fast path split across raw content and editor-ready blocks. `useTabManagement` owns the raw markdown prefetch cache and `useEditorTabSwap` owns the prepared BlockNote block cache. Cached or preloaded markdown is never rendered directly: before reusing it, the renderer calls the `validate_note_content` Tauri command, which compares the cached text with the current file bytes inside the validated vault boundary. If validation fails, Tolaria discards the cached entry and reads fresh disk content before swapping the editor.
|
||||
|
||||
The note list opportunistically preloads visible and adjacent markdown/text entries after a short idle delay. Once raw content resolves, the editor prepares BlockNote blocks in the background when it is mounted and not in raw mode. This targets the expensive markdown-to-editor conversion stage while keeping filesystem content authoritative and keeping preload memory bounded by the same cache limits as ordinary note switches.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -90,6 +112,7 @@ flowchart LR
|
||||
| Frontend | React + TypeScript | React 19, TS 5.9 |
|
||||
| Editor | BlockNote | 0.46.2 |
|
||||
| Code block highlighting | @blocknote/code-block | 0.46.2 |
|
||||
| Diagram rendering | Mermaid | 11.14.0 |
|
||||
| Raw editor | CodeMirror 6 | - |
|
||||
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
|
||||
| UI primitives | Radix UI + shadcn/ui | - |
|
||||
@@ -97,8 +120,10 @@ flowchart LR
|
||||
| Build | Vite | 7.3.1 |
|
||||
| Backend language | Rust (edition 2021) | 1.77.2 |
|
||||
| Frontmatter parsing | gray_matter | 0.2 |
|
||||
| AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - |
|
||||
| Filesystem watcher | notify | 6.1 |
|
||||
| AI (agent panel) | CLI agent adapters (Claude Code + Codex + OpenCode + Pi) | - |
|
||||
| Search | Keyword (walkdir-based file scan) | - |
|
||||
| Localization | App-owned runtime + JSON catalogs (`src/lib/i18n.ts`, `src/lib/locales/*.json`, `lara.yaml`) | English fallback + Lara CLI sync |
|
||||
| MCP | @modelcontextprotocol/sdk | 1.0 |
|
||||
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
|
||||
| Package manager | pnpm | - |
|
||||
@@ -135,7 +160,7 @@ flowchart TD
|
||||
end
|
||||
|
||||
subgraph EXT["External Services"]
|
||||
CCLI["Claude CLI / Codex CLI\n(agent subprocesses)"]
|
||||
CCLI["Claude / Codex / OpenCode / Pi CLI\n(agent subprocesses)"]
|
||||
MCP["MCP Server\n(ws://9710, 9711)"]
|
||||
GCLI["git CLI\n(system executable)"]
|
||||
REMOTE["Git remotes\n(GitHub/GitLab/Gitea/etc.)"]
|
||||
@@ -176,15 +201,20 @@ flowchart TD
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
|
||||
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, while keyboard users can use command-palette move actions. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root.
|
||||
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and rich-editor width toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize.
|
||||
|
||||
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds sidebar / note-list / expanded-inspector allowances on top, and calls the native `update_current_window_min_size` command whenever view mode or inspector visibility changes. That same native command also grows the current window back out when a wider pane combination is restored, while note windows skip this path and keep their dedicated 800×700 initial sizing.
|
||||
|
||||
The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline.
|
||||
|
||||
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that macOS uses for native menu clicks.
|
||||
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` unless the user already set that variable, and on Wayland sessions it re-execs once with the first available system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER` before GTK/WebKit create the display.
|
||||
|
||||
## Multi-Window (Note Windows)
|
||||
|
||||
Notes can be opened in separate Tauri windows for focused editing. Secondary windows show only the editor panel (no sidebar, no note list).
|
||||
@@ -200,7 +230,7 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window)
|
||||
- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance
|
||||
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload
|
||||
- Secondary windows are sized 800×700 with overlay title bar
|
||||
- Secondary windows are sized 800×700; macOS keeps the overlay title bar, while Linux mounts the shared React titlebar on undecorated windows
|
||||
- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels
|
||||
|
||||
## AI System
|
||||
@@ -209,12 +239,13 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
|
||||
Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration.
|
||||
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgents.ts`) — streaming UI with reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
|
||||
2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters
|
||||
3. **Agent adapters** — Claude Code still uses `claude_cli.rs`; Codex runs through `codex exec --json` with the CLI's normal approval / sandbox defaults
|
||||
4. **MCP Integration** — Claude receives the generated MCP config file path, while Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-agent selection, and the per-vault Safe / Power User permission mode shown in the panel header
|
||||
2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
|
||||
3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents
|
||||
4. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools, while Power User adds Bash without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs` and run through `codex --sandbox workspace-write --ask-for-approval never exec --json` in both modes. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config. Gemini runs through `gemini --output-format json --prompt` with Safe using `auto_edit` plus `tools.exclude=["run_shell_command"]` and Power User using `yolo` against a trusted transient Tolaria MCP entry. OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
|
||||
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
|
||||
|
||||
Claude Code availability intentionally does not depend only on the desktop app's inherited `PATH`. The detector checks the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, npm-global, and Homebrew paths so first-run onboarding works on fresh macOS installs.
|
||||
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs.
|
||||
|
||||
#### Agent Event Flow
|
||||
|
||||
@@ -228,12 +259,12 @@ sequenceDiagram
|
||||
|
||||
U->>FE: sendMessage(text, references)
|
||||
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
|
||||
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath})
|
||||
R->>R: pick adapter for claude_code or codex
|
||||
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath, permissionMode})
|
||||
R->>R: pick adapter for claude_code, codex, opencode, or pi
|
||||
R->>C: spawn agent with MCP-enabled config
|
||||
|
||||
loop Normalized stream
|
||||
C-->>R: Claude NDJSON or Codex JSONL events
|
||||
C-->>R: Claude NDJSON, Codex JSONL, OpenCode JSON, or Pi JSON events
|
||||
R-->>FE: emit("ai-agent-stream", event)
|
||||
alt TextDelta
|
||||
FE->>FE: accumulate response (revealed on Done)
|
||||
@@ -255,7 +286,7 @@ sequenceDiagram
|
||||
|
||||
#### File Operation Detection
|
||||
|
||||
When the agent writes or edits vault files, `useCliAiAgent` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload.
|
||||
When the agent writes or edits vault files, `aiAgentFileOperations.ts` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload. Unrecognized write-like operations fall back to a full vault refresh.
|
||||
|
||||
### Context Building
|
||||
|
||||
@@ -275,11 +306,11 @@ Token budget: 60% of 180k context limit (~108k tokens max). Active note gets pri
|
||||
|
||||
### Authentication
|
||||
|
||||
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed. Tolaria does not store model-provider API keys in app settings.
|
||||
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings.
|
||||
|
||||
## MCP Server
|
||||
|
||||
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
|
||||
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Gemini CLI, Cursor, or any MCP-compatible client).
|
||||
|
||||
### Tool Surface (14 tools)
|
||||
|
||||
@@ -310,16 +341,18 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|
||||
### Explicit External Tool Setup
|
||||
|
||||
Tolaria can register itself as an MCP server in:
|
||||
- `~/.claude/mcp.json` (Claude Code)
|
||||
- `~/.claude.json` and `~/.claude/mcp.json` (Claude Code compatibility across current CLI and legacy MCP-file setups)
|
||||
- `~/.gemini/settings.json` (Gemini CLI)
|
||||
- `~/.cursor/mcp.json` (Cursor)
|
||||
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
|
||||
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`).
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
|
||||
|
||||
### Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph MCP["MCP Server (Node.js) — spawned by Tauri on startup"]
|
||||
subgraph MCP["MCP Server (Node.js) — selected-vault scoped"]
|
||||
IDX["index.js"]
|
||||
VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"]
|
||||
WSB["ws-bridge.js"]
|
||||
@@ -331,8 +364,8 @@ flowchart TD
|
||||
WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"]
|
||||
end
|
||||
|
||||
TAURI["Tauri (mcp.rs)"] -->|"spawn on startup"| MCP
|
||||
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude/mcp.json\n~/.cursor/mcp.json"]
|
||||
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"]
|
||||
```
|
||||
|
||||
### WebSocket Bridge
|
||||
@@ -359,11 +392,13 @@ flowchart LR
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
|
||||
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code and Cursor MCP configs on explicit user request |
|
||||
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code and Cursor configs |
|
||||
| `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes |
|
||||
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Gemini CLI, Cursor, and generic MCP configs on user request |
|
||||
| `mcp_config_snippet(vault_path)` | Builds the exact `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files |
|
||||
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, and generic MCP configs |
|
||||
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
|
||||
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed plus waited on app exit via the `RunEvent::Exit` handler. The same desktop layer keeps Tauri asset protocol access limited to vault roots loaded during the current app session; command calls remain active-vault scoped for reads, writes, and external opens.
|
||||
|
||||
## Search
|
||||
|
||||
@@ -404,10 +439,17 @@ flowchart TD
|
||||
|
||||
## Styling
|
||||
|
||||
The app uses a single light theme with no user-configurable theming (see [ADR-0013](adr/0013-remove-theming-system.md)).
|
||||
The app uses internal app-owned light and dark themes (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md)). This is not the old vault-authored theming system from ADR-0013: users choose a mode, but themes are owned by the app.
|
||||
|
||||
1. **Global CSS variables** (`src/index.css`): App-wide colors, borders, backgrounds. Bridged to Tailwind v4 via `@theme inline`.
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`.
|
||||
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states. Bridged to Tailwind v4 via `@theme inline`.
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`; editor colors resolve through the same semantic app variables.
|
||||
3. **Theme runtime**: Applies `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected. Settings and command-palette theme actions both write the same installation-local `settings.theme_mode` value.
|
||||
|
||||
## Localization
|
||||
|
||||
Tolaria's app chrome uses an app-owned localization runtime in `src/lib/i18n.ts`, backed by flat JSON catalogs in `src/lib/locales/` and Lara CLI synchronization through `lara.yaml` (see [ADR-0087](adr/0087-json-catalogs-and-lara-cli-localization.md)). `en.json` is the canonical source catalog, locale files are one file per locale, and English remains the fallback for any missing locale file or key. The installation-local `ui_language` setting stores an explicit locale when the user chooses one; `null` means "follow the system language when Tolaria supports it, otherwise English." Legacy stored values such as `zh-Hans` are normalized to canonical locale codes like `zh-CN`.
|
||||
|
||||
`App.tsx` derives the effective locale from settings and browser/system language hints, then passes it down to localized surfaces. Settings exposes a keyboard-accessible shadcn `Select`, and the command palette includes actions to open language settings or switch directly to a supported language.
|
||||
|
||||
## Vault Management
|
||||
|
||||
@@ -430,6 +472,7 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
|
||||
- `zoom`: Float zoom level (0.8–1.5)
|
||||
- `view_mode`: "all" | "editor-list" | "editor-only"
|
||||
- `editor_mode`: "raw" | "preview" (persists across note switches and sessions)
|
||||
- `note_layout`: "centered" | "left" (wide-screen note column alignment for rich and raw editors)
|
||||
- `tag_colors`, `status_colors`: Custom color overrides
|
||||
- `property_display_modes`: Property display preferences
|
||||
- `inbox.noteListProperties`: Optional Inbox-only property chip override for the note list
|
||||
@@ -440,16 +483,18 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
|
||||
|
||||
On first launch, `useOnboarding` checks if the default vault exists. If not, it shows `WelcomeScreen` with three options:
|
||||
- **Create a new vault** → creates an empty git repo in a folder the user chooses
|
||||
- **Open an existing folder** → system file picker
|
||||
- **Open an existing folder** → system file picker; plain Markdown folders without `.git` open immediately in supported non-git mode
|
||||
- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately
|
||||
|
||||
When an opened folder is not yet a git repo, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
|
||||
|
||||
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
|
||||
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
|
||||
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, OpenCode, Pi, and Gemini CLI are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
|
||||
|
||||
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
|
||||
|
||||
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
|
||||
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. Optional `GEMINI.md` guidance is created only by the explicit AI guidance restore action. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
|
||||
|
||||
After the clone completes, Tolaria removes every configured git remote from the new starter vault. Getting Started vaults therefore open as local-only by default, and users opt into a remote later with the explicit Add Remote flow.
|
||||
|
||||
@@ -460,9 +505,9 @@ Tolaria no longer implements provider-specific OAuth or remote-repository APIs.
|
||||
**Flow:**
|
||||
1. User opens `CloneVaultModal` from onboarding or the vault menu
|
||||
2. User pastes any git URL and chooses a local destination
|
||||
3. `clone_repo()` shells out to `git clone`
|
||||
3. The `clone_git_repo()` Tauri command runs `git clone` inside a blocking Tokio task so the Tauri window stays responsive during slow or failing clones
|
||||
4. `git_push()` / `git_pull()` continue to use the same system git path
|
||||
5. If auth fails, the raw git stderr is surfaced in the UI
|
||||
5. Clone commands disable interactive terminal / askpass prompts and surface the git failure back to the UI instead of freezing the app waiting for input
|
||||
|
||||
**Auth model:**
|
||||
- SSH keys, Git Credential Manager, macOS Keychain helpers, `gh auth`, and other git helpers all work without app-specific setup
|
||||
@@ -493,6 +538,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: run_startup_tasks()<br/>(migrate + seed only)
|
||||
T->>MCP: spawn_ws_bridge() — ports 9710 + 9711
|
||||
T->>A: App mounts
|
||||
@@ -502,7 +548,7 @@ sequenceDiagram
|
||||
A-->>U: WelcomeScreen
|
||||
else Vault found
|
||||
A->>VL: useVaultLoader fires
|
||||
VL->>T: invoke('reload_vault') → sync active vault asset scope + scan_vault_cached()
|
||||
VL->>T: invoke('reload_vault') → allow requested vault roots in asset scope + scan_vault_cached()
|
||||
T-->>VL: VaultEntry[]
|
||||
VL->>T: invoke('get_modified_files')
|
||||
A->>T: useMcpStatus — check explicit MCP setup state
|
||||
@@ -566,7 +612,9 @@ flowchart TD
|
||||
|
||||
`useGitRemoteStatus` re-checks `git_remote_status` when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps the flow local-only: the status bar shows a neutral `No remote` chip, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
|
||||
|
||||
The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds.
|
||||
If the current vault is not a Git repository, Tolaria treats Git as disabled instead of degraded. The status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders.
|
||||
|
||||
The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command ensures the local author identity, adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds.
|
||||
|
||||
`useCommitFlow` also exposes `runAutomaticCheckpoint()`, a dialog-free commit path shared by AutoGit and the bottom-bar Commit button. `useAutoGit` watches the last editor activity plus app focus/visibility state, and when the vault is git-backed, all saves are flushed, and no unsaved edits remain, it triggers the same deterministic `Updated N note(s)` / `Updated N file(s)` commit message path after the configured idle or inactive thresholds. The bottom-bar quick action reuses that checkpoint flow after forcing a save first, so manual quick commits and scheduled AutoGit commits stay aligned on message generation and push behavior.
|
||||
|
||||
@@ -590,10 +638,12 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
|
||||
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
|
||||
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
|
||||
| `ignored.rs` | Gitignored-content visibility filtering via batched `git check-ignore` |
|
||||
| `filename_rules.rs` | Cross-platform validation for note filenames, folder names, and custom view filenames |
|
||||
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
|
||||
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
|
||||
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
|
||||
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
|
||||
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` shims), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
|
||||
| `getting_started.rs` | Clones and normalizes the public Getting Started starter vault |
|
||||
|
||||
## Rust Backend Modules
|
||||
@@ -603,15 +653,17 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
|
||||
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
|
||||
| `search.rs` | Keyword search — walkdir-based vault file scan |
|
||||
| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch |
|
||||
| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing |
|
||||
| `search.rs` | Keyword search — walkdir-based vault file scan with Gitignored-content visibility filtering |
|
||||
| `ai_agents.rs` | CLI-agent request normalization and adapter dispatch |
|
||||
| `cli_agent_runtime.rs` | Shared CLI-agent request, prompt, subprocess, version, and MCP path helpers |
|
||||
| `claude_cli.rs`, `codex_cli.rs`, `opencode_cli.rs` | Claude Code, Codex, and OpenCode command/config/event adapters |
|
||||
| `pi_cli.rs`, `pi_config.rs`, `pi_discovery.rs`, `pi_events.rs` | Pi subprocess launch, transient MCP adapter config, discovery, and JSON stream parsing |
|
||||
| `mcp.rs` | MCP server spawning + explicit config registration/removal |
|
||||
| `commands/` | Tauri command handlers (split into submodules) |
|
||||
| `settings.rs` | App settings persistence |
|
||||
| `vault_config.rs` | Per-vault UI config |
|
||||
| `vault_list.rs` | Vault list persistence |
|
||||
| `menu.rs` | Native macOS menu bar |
|
||||
| `menu.rs` | Native desktop menu definitions and command IDs (not mounted on Linux) |
|
||||
|
||||
## Tauri IPC Commands
|
||||
|
||||
@@ -619,7 +671,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list_vault` | Scan vault (cached) → `Vec<VaultEntry>` |
|
||||
| `list_vault` | Scan vault (cached), then apply Gitignored-content visibility → `Vec<VaultEntry>` |
|
||||
| `get_note_content` | Read note file content |
|
||||
| `save_note_content` | Write note content to disk |
|
||||
| `delete_note` | Permanently delete note from disk (with confirm dialog) |
|
||||
@@ -631,12 +683,14 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow |
|
||||
| `batch_archive_notes` | Archive multiple notes |
|
||||
| `batch_delete_notes` | Permanently delete notes from disk |
|
||||
| `reload_vault` | Sync the active vault asset scope, invalidate cache, and full rescan from filesystem → `Vec<VaultEntry>` |
|
||||
| `reload_vault` | Allow the requested vault roots in the runtime asset scope, invalidate cache, full rescan from filesystem, then apply Gitignored-content visibility → `Vec<VaultEntry>` |
|
||||
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
|
||||
| `open_vault_file_external` | Validate an existing file against the active vault boundary, then open it with the system default app |
|
||||
| `start_vault_watcher` / `stop_vault_watcher` | Start or stop native active-vault filesystem change events |
|
||||
| `check_vault_exists` | Check if vault path exists |
|
||||
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.md` defaults |
|
||||
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
|
||||
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
|
||||
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` guidance are managed, missing, broken, or custom |
|
||||
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
|
||||
|
||||
### Frontmatter
|
||||
@@ -687,13 +741,14 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `stream_claude_chat` | Claude CLI chat mode (streaming) |
|
||||
| `stream_claude_agent` | Claude CLI agent mode (streaming + tools) |
|
||||
| `check_claude_cli` | Check if Claude CLI is available |
|
||||
| `get_ai_agents_status` | Check Claude Code + Codex availability |
|
||||
| `stream_ai_agent` | Stream the selected CLI agent through the normalized event layer |
|
||||
| `register_mcp_tools` | Register MCP in Claude/Cursor config for the active vault |
|
||||
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor config |
|
||||
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor config |
|
||||
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi availability |
|
||||
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, or Pi through the normalized agent event layer |
|
||||
| `register_mcp_tools` | Register MCP in Claude/Gemini/Cursor/generic config for the active vault |
|
||||
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/generic config |
|
||||
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Gemini/Cursor/generic config |
|
||||
| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault |
|
||||
| `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected |
|
||||
|
||||
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
|
||||
|
||||
@@ -709,8 +764,8 @@ The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bri
|
||||
| `save_vault_config` | Save per-vault UI config |
|
||||
| `get_default_vault_path` | Get default vault path |
|
||||
| `get_build_number` | Get app build number |
|
||||
| `save_image` | Save base64 image to vault |
|
||||
| `copy_image_to_vault` | Copy image file to vault |
|
||||
| `save_image` | Save base64 image to `attachments/` and ensure the vault root is in the runtime asset scope |
|
||||
| `copy_image_to_vault` | Copy image file to `attachments/` and ensure the vault root is in the runtime asset scope |
|
||||
| `update_menu_state` | Update native menu checkmarks and enabled/disabled state for selection-dependent actions |
|
||||
| `trigger_menu_command` | Emit a native menu command ID for deterministic shortcut QA |
|
||||
| `update_current_window_min_size` | Update the active Tauri window's minimum size and optionally grow it to fit restored panes |
|
||||
@@ -745,18 +800,20 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
|
||||
| `useNoteRename` | — | Note renaming and folder moves with wikilink update |
|
||||
| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions |
|
||||
| `useTauriDragDropEvent` | — | Shared native window drag/drop event subscription and cleanup |
|
||||
| `useNativePathDrop` | — | Tauri-native file/folder path drops for AI and command text inputs |
|
||||
| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch |
|
||||
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
|
||||
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
|
||||
| `useTheme` | Editor theme CSS vars | Editor typography theme |
|
||||
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation |
|
||||
| `useTheme` | Editor theme CSS vars and theme-mode bridge | Editor typography and app theme runtime |
|
||||
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline and vault permission mode |
|
||||
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
|
||||
| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints |
|
||||
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
|
||||
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
|
||||
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
|
||||
| `useSettings` | App settings (telemetry, release channel, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
|
||||
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences, AI permission mode | Vault-specific config |
|
||||
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
|
||||
|
||||
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
|
||||
@@ -769,20 +826,24 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
|
||||
| Cmd+P / Cmd+O | Open quick open palette |
|
||||
| Cmd+N | Create new note |
|
||||
| Cmd+S | Save current note |
|
||||
| Cmd+F | Find in current note when the editor is focused; otherwise note-list search can claim it |
|
||||
| Cmd+Shift+F | Find in vault |
|
||||
| Cmd+[ / Cmd+] | Navigate back / forward (replaces tabs) |
|
||||
| Cmd+Z / Cmd+Shift+Z | Undo / Redo |
|
||||
| Cmd+1–9 | Switch to tab N |
|
||||
| Cmd+[ / Cmd+] | Navigate back / forward |
|
||||
| `[[` in editor | Open wikilink suggestion menu |
|
||||
|
||||
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Rename Folder" and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
|
||||
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
|
||||
|
||||
Shortcut routing is explicit:
|
||||
|
||||
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
|
||||
- `formatShortcutDisplay()` derives platform-accurate visible shortcut labels (`⌘` on macOS, `Ctrl` on Windows/Linux) from that same manifest so menus, tooltips, and command-palette copy stay aligned with real accelerators
|
||||
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
|
||||
- macOS browser-reserved chords such as `Cmd+O` and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
|
||||
- `menu.rs` and `useMenuEvents` emit the same command IDs for native menu clicks and accelerators
|
||||
- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
|
||||
- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active
|
||||
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same command IDs for native menu clicks, accelerators, and custom titlebar menu actions
|
||||
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
|
||||
- Deterministic QA uses two explicit proof paths from the shared manifest:
|
||||
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`
|
||||
@@ -803,9 +864,13 @@ push to main
|
||||
→ if stable already uses today, advance alpha to the next calendar day so semver still increases
|
||||
→ build job:
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app
|
||||
→ upload signed .app.tar.gz + .sig updater artifacts
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin --bundles app
|
||||
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig updater artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
|
||||
→ build-windows job:
|
||||
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
|
||||
→ release job:
|
||||
→ generate alpha-latest.json
|
||||
→ 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
|
||||
→ pages job:
|
||||
→ build static HTML release history page
|
||||
@@ -822,13 +887,20 @@ push stable-vYYYY.M.D tag
|
||||
→ version job: validate YYYY.M.D from the tag
|
||||
→ build job:
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
|
||||
→ upload signed .app.tar.gz + .sig and .dmg artifacts
|
||||
→ 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
|
||||
→ 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
|
||||
→ release job:
|
||||
→ generate stable-latest.json with both updater tarball and current stable DMG URLs
|
||||
→ generate stable-latest.json with macOS Apple Silicon, macOS Intel, Linux, and Windows updater URLs plus platform-specific manual download URLs
|
||||
→ publish GitHub release Tolaria YYYY.M.D
|
||||
→ pages job:
|
||||
→ publish stable/latest.json
|
||||
→ publish stable/download/ and download/ as permanent redirect URLs for the latest stable DMG
|
||||
→ publish stable/download/ and download/ as permanent redirect URLs for the latest stable platform installer
|
||||
→ preserve alpha/latest.json
|
||||
→ deploy to gh-pages
|
||||
```
|
||||
@@ -869,7 +941,7 @@ sequenceDiagram
|
||||
App->>User: TelemetryConsentDialog
|
||||
alt Accept
|
||||
User->>Settings: telemetry_consent=true, anonymous_id=UUID
|
||||
Settings->>Sentry: init(DSN, anonymous_id)
|
||||
Settings->>Sentry: init(DSN, release, anonymous_id)
|
||||
Settings->>PostHog: init(key, anonymous_id)
|
||||
else Decline
|
||||
User->>Settings: telemetry_consent=false
|
||||
@@ -891,6 +963,7 @@ sequenceDiagram
|
||||
**Architecture:**
|
||||
- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()`
|
||||
- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook; the React root also wires `onCaughtError`, `onUncaughtError`, and `onRecoverableError` through `Sentry.reactErrorHandler()` so production React invariants include component stack context when crash reporting is enabled.
|
||||
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version, but the app only assigns Sentry's `release` field for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds omit `release` so they do not create normal Sentry Releases entries, while both frontend and Rust Sentry scopes tag `tolaria.build_version` and `tolaria.release_kind` for diagnostics.
|
||||
- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct
|
||||
- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null`
|
||||
|
||||
@@ -945,7 +1018,7 @@ Desktop-only modules gated at the crate level:
|
||||
Desktop-only features gated at the function level in `commands/`:
|
||||
- Git operations (commit, pull, push, status, history, diff, conflicts)
|
||||
- Clone-by-URL via system git (`clone_repo`)
|
||||
- CLI AI agent streaming (Claude, Codex)
|
||||
- CLI AI agent streaming (Claude, Codex, OpenCode, Pi)
|
||||
- MCP registration and status
|
||||
- Menu state updates
|
||||
|
||||
|
||||
@@ -8,6 +8,43 @@ How to navigate the codebase, run the app, and find what you need.
|
||||
- **Rust** 1.77.2+ (for the Tauri backend)
|
||||
- **git** CLI (required by the git integration features)
|
||||
|
||||
### Linux system dependencies
|
||||
|
||||
If you run the desktop app on Linux, install Tauri's WebKit2GTK 4.1 dependencies first:
|
||||
|
||||
- Arch / Manjaro:
|
||||
```bash
|
||||
sudo pacman -S --needed webkit2gtk-4.1 base-devel curl wget file openssl \
|
||||
appmenu-gtk-module libappindicator-gtk3 librsvg
|
||||
```
|
||||
- Debian / Ubuntu (22.04+):
|
||||
```bash
|
||||
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
|
||||
libsoup-3.0-dev patchelf
|
||||
```
|
||||
- Fedora 38+:
|
||||
```bash
|
||||
sudo dnf install webkit2gtk4.1-devel openssl-devel curl wget file \
|
||||
libappindicator-gtk3-devel librsvg2-devel
|
||||
```
|
||||
|
||||
### Linux AppImage Wayland troubleshooting
|
||||
|
||||
On some Wayland systems, the Linux AppImage may fail to launch with:
|
||||
|
||||
```text
|
||||
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
|
||||
```
|
||||
|
||||
Recent Tolaria AppImages automatically retry startup with the system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
|
||||
|
||||
```bash
|
||||
LD_PRELOAD=/usr/lib/libwayland-client.so ./Tolaria*.AppImage
|
||||
```
|
||||
|
||||
If your distribution stores the library elsewhere, use that path instead, for example `/usr/lib64/libwayland-client.so.0` or `/usr/lib/x86_64-linux-gnu/libwayland-client.so.0`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -42,8 +79,8 @@ tolaria/
|
||||
│ ├── App.css # App shell layout styles
|
||||
│ ├── types.ts # Shared TS types (VaultEntry, Settings, etc.)
|
||||
│ ├── mock-tauri.ts # Mock Tauri layer for browser testing
|
||||
│ ├── theme.json # Editor theme configuration
|
||||
│ ├── index.css # Global CSS variables + Tailwind setup
|
||||
│ ├── theme.json # Editor typography theme configuration
|
||||
│ ├── index.css # Semantic app theme variables + Tailwind setup
|
||||
│ │
|
||||
│ ├── components/ # UI components (~98 files)
|
||||
│ │ ├── Sidebar.tsx # Left panel: filters + type groups
|
||||
@@ -58,7 +95,7 @@ tolaria/
|
||||
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
|
||||
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
|
||||
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
|
||||
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent)
|
||||
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent + per-vault permission mode)
|
||||
│ │ ├── AiMessage.tsx # Agent message display
|
||||
│ │ ├── AiActionCard.tsx # Agent tool action cards
|
||||
│ │ ├── AiAgentsOnboardingPrompt.tsx # First-launch AI agent installer prompt
|
||||
@@ -68,6 +105,8 @@ 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
|
||||
│ │ ├── 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
|
||||
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
|
||||
@@ -83,16 +122,16 @@ tolaria/
|
||||
│ │ └── ui/ # shadcn/ui primitives
|
||||
│ │ ├── button.tsx, dialog.tsx, input.tsx, ...
|
||||
│ │
|
||||
│ ├── hooks/ # Custom React hooks (~87 files)
|
||||
│ ├── hooks/ # Custom React hooks (~86 files)
|
||||
│ │ ├── useVaultLoader.ts # Loads vault entries + content
|
||||
│ │ ├── useVaultSwitcher.ts # Multi-vault management
|
||||
│ │ ├── useVaultConfig.ts # Per-vault UI settings
|
||||
│ │ ├── useNoteActions.ts # Composes creation + rename + frontmatter
|
||||
│ │ ├── useNoteCreation.ts # Note/type creation
|
||||
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
|
||||
│ │ ├── useAiAgent.ts # Legacy Claude-specific stream helpers reused by the shared agent hook
|
||||
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized tool tracking
|
||||
│ │ ├── useAiAgentsStatus.ts # Claude/Codex availability polling
|
||||
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
|
||||
│ │ ├── aiAgentPermissionMode.ts # Safe/Power User mode normalization + labels
|
||||
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi/Gemini availability polling
|
||||
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
|
||||
│ │ ├── useAiActivity.ts # MCP UI bridge listener
|
||||
│ │ ├── useAutoSync.ts # Auto git pull/push
|
||||
@@ -118,6 +157,7 @@ tolaria/
|
||||
│ ├── utils/ # Pure utility functions (~48 files)
|
||||
│ │ ├── wikilinks.ts # Wikilink preprocessing pipeline
|
||||
│ │ ├── frontmatter.ts # TypeScript YAML parser
|
||||
│ │ ├── platform.ts # Runtime platform + Linux chrome gating helpers
|
||||
│ │ ├── ai-agent.ts # Agent stream utilities
|
||||
│ │ ├── ai-chat.ts # Token estimation utilities
|
||||
│ │ ├── ai-context.ts # Context snapshot builder
|
||||
@@ -133,6 +173,8 @@ tolaria/
|
||||
│ ├── lib/
|
||||
│ │ ├── aiAgents.ts # Shared agent registry + status helpers
|
||||
│ │ ├── appUpdater.ts # Frontend wrapper around channel-aware updater commands
|
||||
│ │ ├── i18n.ts # App-owned localization runtime and locale resolution
|
||||
│ │ ├── locales/ # JSON locale catalogs (English source + translated locales)
|
||||
│ │ ├── releaseChannel.ts # Alpha/stable normalization helpers
|
||||
│ │ └── utils.ts # Tailwind merge + cn() helper
|
||||
│ │
|
||||
@@ -163,8 +205,11 @@ tolaria/
|
||||
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
|
||||
│ │ ├── telemetry.rs # Sentry init + path scrubber
|
||||
│ │ ├── search.rs # Keyword search (walkdir-based)
|
||||
│ │ ├── ai_agents.rs # Shared CLI-agent detection + stream adapters
|
||||
│ │ ├── claude_cli.rs # Claude CLI subprocess management
|
||||
│ │ ├── ai_agents.rs # CLI-agent request normalization + adapter dispatch
|
||||
│ │ ├── cli_agent_runtime.rs # Shared CLI-agent runtime process/prompt/MCP helpers
|
||||
│ │ ├── claude_cli.rs # Claude CLI adapter
|
||||
│ │ ├── codex_cli.rs # Codex CLI adapter
|
||||
│ │ ├── pi_cli.rs # Pi CLI adapter
|
||||
│ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal
|
||||
│ │ ├── app_updater.rs # Alpha/stable updater endpoint selection
|
||||
│ │ ├── settings.rs # App settings persistence
|
||||
@@ -187,6 +232,7 @@ tolaria/
|
||||
├── scripts/ # Build/utility scripts
|
||||
│
|
||||
├── package.json # Frontend dependencies + scripts
|
||||
├── lara.yaml # Lara CLI locale sync configuration
|
||||
├── vite.config.ts # Vite bundler config
|
||||
├── tsconfig.json # TypeScript config
|
||||
├── playwright.config.ts # Full Playwright regression config
|
||||
@@ -234,8 +280,9 @@ tolaria/
|
||||
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
|
||||
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). |
|
||||
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
|
||||
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, safe-default Codex adapter, and stream normalization. |
|
||||
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
|
||||
| `src-tauri/src/ai_agents.rs` | CLI-agent request normalization, availability aggregation, adapter dispatch, and Claude event mapping. |
|
||||
| `src-tauri/src/cli_agent_runtime.rs` | Shared CLI-agent request shape, prompt wrapping, JSON subprocess lifecycle, version probing, and MCP path helpers. |
|
||||
| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `src-tauri/src/gemini_cli.rs` | Per-agent command, config, discovery, and JSON event adapters. |
|
||||
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
|
||||
|
||||
### Editor
|
||||
@@ -254,8 +301,11 @@ tolaria/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, and actions. |
|
||||
| `src/hooks/useCliAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. |
|
||||
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, actions, and per-vault permission mode. |
|
||||
| `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. |
|
||||
| `src/lib/aiAgentSession.ts` | Single message/session lifecycle for prompt normalization, history, streaming, and reset behavior. |
|
||||
| `src/lib/aiAgentPermissionMode.ts` | Safe/Power User mode normalization, display labels, and local transcript marker text. |
|
||||
| `src/lib/aiAgentFileOperations.ts` | Detects agent-created or modified vault files from normalized tool inputs. |
|
||||
| `src/lib/aiAgents.ts` | Supported agent definitions, status normalization, and default-agent helpers. |
|
||||
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
|
||||
|
||||
@@ -263,19 +313,19 @@ tolaria/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/index.css` | All CSS custom properties. The design token source of truth. |
|
||||
| `src/theme.json` | Editor-specific theme (fonts, headings, lists, code blocks). |
|
||||
| `src/index.css` | Semantic CSS custom properties for app-owned light/dark themes. |
|
||||
| `src/theme.json` | Editor-specific typography theme (fonts, headings, lists, code blocks). |
|
||||
|
||||
### Settings & Config
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, auto-sync interval, default AI agent). |
|
||||
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, 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). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, 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, AI permission mode). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, default AI agent, and the vault-level explicit organization toggle. |
|
||||
| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
|
||||
|
||||
## Architecture Patterns
|
||||
@@ -311,10 +361,12 @@ type SidebarSelection =
|
||||
|
||||
### Command Registry
|
||||
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
|
||||
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
|
||||
|
||||
Current-note find/replace is a surface-aware command: editor focus enables "Find in Note" / "Replace in Note" and routes Cmd+F into raw CodeMirror mode; note-list focus enables existing note-list search instead. When adding another focus-dependent command, mirror this pattern with an availability event consumed by `useMenuEvents.ts` and `update_menu_state`.
|
||||
|
||||
For automated shortcut QA, use the explicit proof path from `appCommandCatalog.ts`:
|
||||
|
||||
- `window.__laputaTest.triggerShortcutCommand()` for deterministic renderer shortcut-event coverage
|
||||
@@ -366,7 +418,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
|
||||
### Add a new entity type
|
||||
|
||||
1. Create a type document: `type/mytype.md` with `type: Type` frontmatter (icon, color, order, etc.)
|
||||
1. Create a type document at the vault root: `mytype.md` with `type: Type` frontmatter (icon, color, order, etc.)
|
||||
2. The sidebar section groups are auto-generated from type documents — no code change needed if `visible: true`
|
||||
3. Update `CreateNoteDialog.tsx` type options if users should be able to create it from the dialog
|
||||
4. Notes of this type are created at the vault root with `type: MyType` in frontmatter — no dedicated folder needed
|
||||
@@ -380,7 +432,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
|
||||
### Modify styling
|
||||
|
||||
1. **Global CSS variables**: Edit `src/index.css`
|
||||
1. **Global app/theme variables**: Edit `src/index.css`
|
||||
2. **Editor typography**: Edit `src/theme.json`
|
||||
|
||||
### Work with the AI agent
|
||||
@@ -388,5 +440,14 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string)
|
||||
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
|
||||
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
|
||||
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`)
|
||||
5. **Shared agent adapters / Codex args**: Edit `src-tauri/src/ai_agents.rs` (keep Codex on the normal approval/sandbox path unless you are intentionally designing an advanced mode)
|
||||
4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts`
|
||||
5. **Shared CLI runtime behavior**: Edit `src-tauri/src/cli_agent_runtime.rs` for process lifecycle, prompt wrapping, version probing, and common Tolaria MCP path handling.
|
||||
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`). Keep Codex sandboxed with active-vault `workspace-write`, keep Pi and Gemini on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode.
|
||||
|
||||
### Work with external MCP setup
|
||||
|
||||
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
|
||||
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
|
||||
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
|
||||
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; app-managed Gemini sessions still require the user to install and sign in to Gemini CLI, but Tolaria supplies transient MCP settings when Gemini is selected as the default AI agent
|
||||
5. **Process lifecycle**: Stdio MCP servers in `mcp-server/index.js` must exit when their external client closes stdin, and the desktop-owned `ws-bridge.js` child must be stopped on vault deselection, vault switch, and app exit
|
||||
|
||||
53
docs/LARGE-VAULT-LOADING-QA.md
Normal file
53
docs/LARGE-VAULT-LOADING-QA.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Large Vault Loading QA
|
||||
|
||||
Use this when validating startup responsiveness for large vaults. The goal is to make the bottleneck reproducible without using a real user vault.
|
||||
|
||||
## Synthetic Vault
|
||||
|
||||
Create a disposable vault with many markdown files:
|
||||
|
||||
```bash
|
||||
VAULT="$(mktemp -d /tmp/tolaria-large-vault.XXXXXX)"
|
||||
mkdir -p "$VAULT/type" "$VAULT/archive" "$VAULT/assets"
|
||||
cat > "$VAULT/type/project.md" <<'EOF'
|
||||
---
|
||||
is_a: Type
|
||||
---
|
||||
# Project
|
||||
EOF
|
||||
|
||||
for i in $(seq -w 1 20000); do
|
||||
cat > "$VAULT/project-$i.md" <<EOF
|
||||
---
|
||||
is_a: Project
|
||||
status: Active
|
||||
related_to:
|
||||
- "[[project-00001]]"
|
||||
---
|
||||
# Project $i
|
||||
|
||||
Synthetic body $i with enough text to exercise parsing and snippets.
|
||||
EOF
|
||||
done
|
||||
|
||||
git -C "$VAULT" init
|
||||
git -C "$VAULT" config user.email qa@example.invalid
|
||||
git -C "$VAULT" config user.name "Tolaria QA"
|
||||
git -C "$VAULT" add .
|
||||
git -C "$VAULT" commit -m "seed large vault"
|
||||
echo "$VAULT"
|
||||
```
|
||||
|
||||
## Manual QA
|
||||
|
||||
1. Start Tolaria with `pnpm tauri dev`.
|
||||
2. Open the synthetic vault path printed above.
|
||||
3. Verify the main shell renders before the full note list finishes indexing.
|
||||
4. Confirm the status bar shows vault activity while indexing is still in progress.
|
||||
5. Use keyboard-only flows while indexing continues:
|
||||
- Cmd+K opens the command palette.
|
||||
- Cmd+P opens quick open; results may be partial or empty until indexing finishes.
|
||||
- Create a new note with Cmd+N and type in the editor.
|
||||
6. Wait for indexing to finish and verify the note list/search state is consistent.
|
||||
|
||||
The synthetic vault lives under `/tmp`; remove it after QA if it is no longer needed.
|
||||
@@ -2,8 +2,9 @@
|
||||
type: ADR
|
||||
id: "0013"
|
||||
title: "Remove vault-based theming system"
|
||||
status: active
|
||||
status: superseded
|
||||
date: 2026-03-23
|
||||
superseded_by: "0081"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
37
docs/adr/0077-concurrent-safe-vault-cache-replacement.md
Normal file
37
docs/adr/0077-concurrent-safe-vault-cache-replacement.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0077"
|
||||
title: "Concurrent-safe vault cache replacement"
|
||||
status: active
|
||||
date: 2026-04-24
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0014 and ADR-0024 established Tolaria's git-based persistent vault cache and moved it outside the vault directory. That cache was still being rewritten with a simple temp-file-and-rename flow.
|
||||
|
||||
Once Tolaria started reopening the same vault from multiple windows/processes more often, that write model became too optimistic: two scans could both build valid cache payloads from different moments in time, and the slower writer could still atomically replace a fresher cache written by another window. The cache needed cross-window safety without introducing a long-lived coordinator process or making vault open dependent on heavyweight IPC.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria now treats vault-cache replacement as a best-effort compare-and-swap operation instead of an unconditional atomic overwrite.**
|
||||
|
||||
- Each scan still builds the next cache payload in memory and writes it to a temp file first.
|
||||
- Before replacing the real cache file, Tolaria acquires a short-lived lock file for that vault cache path.
|
||||
- After the lock is acquired, Tolaria rechecks the on-disk cache fingerprint and only renames the temp file into place if another window/process has not already refreshed the cache.
|
||||
- If the cache changed underneath the current scan, Tolaria skips the replace and keeps the newer on-disk cache.
|
||||
- Stale cache-write locks are garbage-collected after a short timeout so a crashed writer does not block future refreshes.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Lock + fingerprint guarded replacement** (chosen): keeps the cache file external and file-based, avoids overwriting fresher cache state from another Tolaria window, and preserves graceful fallback to filesystem rescans. Cons: cache writes become best-effort rather than guaranteed after every scan.
|
||||
- **Keep unconditional temp-file + rename**: simplest implementation, but concurrent windows can regress the cache to an older view even though each individual replace is atomic.
|
||||
- **Centralized cache service or long-lived process mutex**: strongest coordination story, but too much operational complexity for a local desktop app and would create new failure modes around boot, process lifetime, and IPC.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Tolaria's cache correctness model is now "latest successful guarded replace wins," not "every scan must write a cache file."
|
||||
- Cache refreshes must tolerate a skipped write when another window/process already produced a fresher cache.
|
||||
- Temp-file writes and renames still provide corruption resistance, but freshness is protected separately by the writer lock and fingerprint check.
|
||||
- Cache-write failures remain non-fatal: Tolaria logs them and falls back to rebuilding from the filesystem when needed.
|
||||
- Re-evaluate if Tolaria later needs stronger cross-process coordination than lock-file + fingerprint checks can provide.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0078"
|
||||
title: "Scoped unsigned fallback for app-managed git commits"
|
||||
status: active
|
||||
date: 2026-04-24
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0021, ADR-0059, and ADR-0070 all assume Tolaria can create and advance a local git-backed vault without asking users to debug git internals first. In practice, inherited `commit.gpgsign` settings were breaking that promise: a missing or misconfigured GPG/SSH signing helper could block the initial `Initial vault setup` commit during onboarding and could also strand later app-triggered commits behind opaque signing failures.
|
||||
|
||||
Tolaria needed a policy that kept signed workflows intact when the user's signing setup actually works, while still ensuring app-managed git operations do not become unusable just because a desktop environment cannot reach the signing helper.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria uses a scoped unsigned fallback for app-managed commits instead of requiring signing to succeed unconditionally.**
|
||||
|
||||
- The onboarding/setup commit (`Initial vault setup`) always runs with `commit.gpgsign=false` for that single git invocation.
|
||||
- Normal app-managed `git_commit` calls still honor the user's existing git signing configuration first.
|
||||
- If a commit fails and Git's error matches a signing-helper failure, Tolaria retries that same app-managed commit once with signing disabled.
|
||||
- Tolaria does not rewrite the user's git config and does not broaden the retry to unrelated commit failures.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Scoped unsigned fallback for app-managed commits** (chosen): keeps onboarding and local commit flows resilient while still preserving signed commits when the user's environment supports them. Cons: some Tolaria-created commits may be unsigned on machines with broken signing setups.
|
||||
- **Require signing to succeed for every commit**: simplest policy, but it turns missing desktop GPG/SSH helpers into app-breaking failures during onboarding and normal use.
|
||||
- **Disable signing for all Tolaria-triggered commits**: maximally robust, but it would silently bypass working signing setups and weaken users' expected git security posture.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New vault creation is no longer blocked by inherited signing settings that only fail in Tolaria's app context.
|
||||
- Users with healthy signing setups still get signed Tolaria commits after the first normal attempt succeeds.
|
||||
- Signing-failure detection must stay narrow so Tolaria does not mask unrelated git errors behind an unsigned retry.
|
||||
- Tolaria's git integration now explicitly prefers "complete the app-managed commit safely" over "preserve signing at all costs" when the signing helper is unavailable.
|
||||
- Re-evaluate if Tolaria later exposes per-vault git policy controls or needs a richer user-facing explanation for when a fallback unsigned commit was used.
|
||||
37
docs/adr/0079-linux-window-chrome-and-menu-reuse.md
Normal file
37
docs/adr/0079-linux-window-chrome-and-menu-reuse.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0079"
|
||||
title: "Linux window chrome and menu reuse"
|
||||
status: active
|
||||
date: 2026-04-24
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's desktop shell was designed around macOS window chrome. `titleBarStyle: "Overlay"` and `hiddenTitle: true` give the app a clean single-surface titlebar on macOS, but Linux ignores those flags and draws native GTK decorations and a native menu bar on top of the React UI. That creates a double-titlebar effect, mismatched theming, and inconsistent behavior between the main window and detached note windows.
|
||||
|
||||
We still need Linux to reuse Tolaria's existing command palette, shortcut manifest, and deterministic menu-command routing instead of inventing a Linux-only command path.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria uses custom React-rendered window chrome on Linux and routes its menu through the existing shared command IDs.**
|
||||
|
||||
- The main Tauri window disables server-side decorations on Linux during app setup.
|
||||
- Detached note windows set `decorations: false` when Linux chrome is active.
|
||||
- `LinuxTitlebar` renders the drag region, resize handles, and window controls for Linux windows.
|
||||
- `LinuxMenuButton` mirrors the app's File/Edit/View/Go/Note/Vault/Window menus, but dispatches the existing command IDs through `trigger_menu_command`.
|
||||
- The native Tauri menu bar is not mounted on Linux; macOS and other existing desktop targets keep the native menu.
|
||||
- Shared shortcuts remain defined in `appCommandCatalog.ts`, including `Cmd+Shift+L` on macOS and `Ctrl+Shift+L` on Linux through the same command manifest.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **React-rendered Linux chrome with shared command IDs** (chosen): keeps Linux visually aligned with Tolaria's existing shell and preserves one command-routing model across keyboard shortcuts, menu clicks, and QA helpers. Cons: Tolaria now owns Linux window chrome behavior directly.
|
||||
- **Keep native GTK decorations and menu bar on Linux**: cheaper to ship, but it breaks visual consistency and produces overlapping titlebar/menu surfaces that do not match the rest of the app.
|
||||
- **Introduce Linux-only command wiring for the custom menu**: would allow a Linux-specific implementation, but it would fork the shortcut/menu architecture and weaken deterministic QA.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Linux main windows and detached note windows now present one consistent titlebar surface controlled by Tolaria.
|
||||
- Menu commands, command palette actions, and deterministic QA still share the same command IDs, which limits platform-specific drift.
|
||||
- Linux packaging and CI must install WebKit2GTK 4.1 dependencies and produce Linux bundles explicitly.
|
||||
- Tolaria now owns Linux resize handles, maximize/minimize/close behavior, and titlebar drag-region behavior in the renderer, so regressions in those surfaces require direct tests.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0080"
|
||||
title: "Cross-platform desktop release artifacts and portable vault names"
|
||||
status: active
|
||||
date: 2026-04-24
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's release pipeline and file validation rules were still biased toward macOS. Alpha/stable releases only produced first-class macOS artifacts, stable download redirects assumed a DMG-only world, and vault file/folder validation allowed names that work on macOS/Linux but break on Windows clones and sync targets.
|
||||
|
||||
Shipping Windows as a supported desktop target requires both distribution and data portability to become explicit. A Windows installer is not enough if shared vault content can still produce invalid filenames on that platform, and cross-platform updater manifests must keep Tauri's signed updater artifact separate from the user-facing installer download.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria ships first-class macOS, Windows x64, and Linux x64 desktop artifacts, and its vault-facing filename rules are portable across those platforms by default.**
|
||||
|
||||
- Alpha and stable release workflows build and publish macOS, Windows x64, and Linux x64 artifacts from the same release tag/version computation.
|
||||
- `latest.json` manifests continue to point Tauri updater clients at signed updater artifacts through `url`, while manual installer/download links are exposed separately via platform-specific fields such as `dmg_url` and `download_url`.
|
||||
- The stable download page resolves the best current platform download from that manifest plus release assets, instead of assuming macOS-only DMG delivery.
|
||||
- Note filename renames, folder creation/rename flows, and custom view filenames all share one portable validation rule set that rejects Windows reserved device names, invalid characters, and trailing dot/space suffixes.
|
||||
- Shortcut labels shown in the UI are derived from the shared command manifest so non-macOS builds display `Ctrl`-style accelerators instead of macOS glyphs.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Cross-platform artifacts + portable filename rules** (chosen): makes Windows support real instead of nominal, keeps updater behavior compatible with Tauri, and prevents cross-OS vault breakage at the point of write. Cons: more CI matrix surface area and more platform-specific packaging constraints.
|
||||
- **Ship Windows installers but keep existing filename validation**: lowers immediate implementation cost, but Windows users would still hit invalid vault content created elsewhere and trust in sync portability would stay weak.
|
||||
- **Keep macOS-first updater/download metadata and infer other platforms from release assets only**: cheaper in the short term, but it weakens in-app update guarantees and makes the public download page depend on ad hoc asset naming rather than an explicit manifest contract.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Tolaria's release CI now owns packaging and artifact validation on three desktop platforms instead of one.
|
||||
- The public stable download page can redirect Windows/Linux users to real installers without special-case manual curation.
|
||||
- Vault content created through Tolaria stays portable across macOS, Linux, and Windows, which reduces sync-time surprises and broken clones.
|
||||
- Any future platform addition now needs both a release-artifact contract and an explicit portable-filename review instead of piggybacking on macOS assumptions.
|
||||
43
docs/adr/0081-internal-light-dark-theme-runtime.md
Normal file
43
docs/adr/0081-internal-light-dark-theme-runtime.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0081"
|
||||
title: "Internal light and dark theme runtime"
|
||||
status: active
|
||||
date: 2026-04-24
|
||||
supersedes: "0013"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0013 removed the vault-authored theming system and made Tolaria light-only. That kept the app simpler, but dark mode has become a product requirement for long writing sessions and accessibility.
|
||||
|
||||
The previous theming system should not return in its old form: vault notes, live user-authored themes, and broad runtime editing created too much maintenance burden. Tolaria still needs a small app-owned theme architecture because the UI spans Tailwind/shadcn variables, BlockNote/Mantine surfaces, CodeMirror raw editing, syntax highlighting, and product-specific states such as selected rows, badges, warnings, and diff lines.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria will support internal app-owned light and dark themes through a semantic CSS-variable contract, with the user's theme mode persisted as installation-local app settings.**
|
||||
|
||||
The v1 theme runtime is deliberately smaller than a general theming system:
|
||||
|
||||
- Themes are defined by the app, not by vault-authored notes.
|
||||
- CSS custom properties remain the public runtime contract for product components, Tailwind v4, and shadcn/ui.
|
||||
- Typed TypeScript helpers may derive values for consumers that cannot read CSS variables directly, such as CodeMirror extensions.
|
||||
- Existing CSS variables stay available as compatibility aliases while the UI migrates toward semantic names.
|
||||
- The first persisted choices are `light` and `dark`; system-follow, high-contrast variants, custom themes, and per-vault themes are deferred.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Internal light/dark runtime with semantic tokens** (chosen): ships dark mode while keeping the product-owned theme surface small, testable, and compatible with existing CSS-variable usage.
|
||||
- **Reintroduce vault-authored theme notes**: flexible, but repeats the complexity removed by ADR-0013 and makes dark mode dependent on user-editable data.
|
||||
- **Ad hoc `.dark` overrides in components**: fastest initially, but would scatter color logic across the app and make future theme variants expensive.
|
||||
- **Single TypeScript theme object as source of truth**: attractive for validation, but the current app already relies on CSS variables for Tailwind, shadcn/ui, BlockNote CSS overrides, and many product components.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `src/index.css` owns the stable CSS custom-property contract for app chrome and shared states.
|
||||
- `src/theme.json` continues to describe editor typography, but editor-facing colors should resolve through the same semantic CSS variables used by the app shell.
|
||||
- `useTheme` remains responsible for editor theme flattening and can grow into the bridge between app theme mode and editor consumers.
|
||||
- App settings, not vault frontmatter, store the selected theme mode because it is an installation-local comfort preference.
|
||||
- Startup must avoid a light-mode flash when dark mode is selected, so the runtime needs a pre-React localStorage mirror and a minimal `index.html` prepaint style in addition to persisted Tauri settings.
|
||||
- Domain tokens should be introduced only when a surface needs a role that generic semantic tokens cannot express clearly.
|
||||
- Re-evaluate if Tolaria decides to support user-authored custom themes, per-vault themes, or system-synchronized mode as a first-class product requirement.
|
||||
40
docs/adr/0082-markdown-durable-math-notes.md
Normal file
40
docs/adr/0082-markdown-durable-math-notes.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0082"
|
||||
title: "Markdown-durable math in notes"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria notes are durable Markdown files, while the main editor uses BlockNote and raw mode uses CodeMirror. Users coming from technical note-taking tools expect inline math such as `$E=mc^2$` and display math such as `$$ ... $$` to render inside notes without turning the note into an app-only document format.
|
||||
|
||||
BlockNote does not currently ship a first-party math block in the local editor package. Tiptap now offers an official Mathematics extension that renders KaTeX nodes, but Tolaria's save path still depends on BlockNote's Markdown parser and `blocksToMarkdownLossy()` serializer. Adding opaque ProseMirror math nodes without an explicit Tolaria serializer would risk losing or rewriting the original Markdown source.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria will support note math through a Markdown placeholder round-trip owned by the editor pipeline.**
|
||||
|
||||
The initial implementation:
|
||||
|
||||
- Treats `$...$` as inline math and line-owned `$$...$$` / multiline `$$` blocks as display math.
|
||||
- Converts math source to temporary placeholders before BlockNote parses Markdown.
|
||||
- Replaces placeholders with Tolaria schema nodes that render via the existing `katex` dependency.
|
||||
- Serializes those schema nodes back to the original Markdown delimiters before saving or entering raw mode.
|
||||
- Uses KaTeX with `throwOnError: false` and `trust: false` so malformed or untrusted formulas remain visible rather than breaking the note.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Tolaria-owned placeholder round-trip with KaTeX rendering** (chosen): matches the existing wikilink architecture, preserves plain-text source, and avoids depending on BlockNote support for non-default ProseMirror math nodes.
|
||||
- **Tiptap Mathematics extension directly in BlockNote**: attractive because it is official Tiptap and KaTeX-backed, but it does not by itself solve Tolaria's BlockNote Markdown serializer contract.
|
||||
- **Raw-mode-only math support**: preserves source but fails the rich editor reading experience users expect.
|
||||
- **Store formulas as custom JSON/frontmatter metadata**: richer structured editing later, but violates the Markdown-first durability requirement.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `src/utils/mathMarkdown.ts` is the canonical parser/serializer bridge for note math.
|
||||
- The rich editor renders math as schema nodes; raw mode remains the most direct way to edit exact math source.
|
||||
- CodeMirror raw editing keeps the literal Markdown delimiters, so imported Obsidian-style notes remain understandable outside Tolaria.
|
||||
- Future equation editing helpers can be added on top of the same Markdown source contract instead of changing the storage model.
|
||||
- Re-evaluate direct Tiptap Mathematics integration only if it can be proven to preserve Tolaria's Markdown save path without custom lossy behavior.
|
||||
38
docs/adr/0083-dual-architecture-macos-release-artifacts.md
Normal file
38
docs/adr/0083-dual-architecture-macos-release-artifacts.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0083"
|
||||
title: "Dual-architecture macOS release artifacts"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
supersedes: "0080"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0080 made Tolaria's desktop release pipeline cross-platform, but the macOS leg still shipped only Apple Silicon artifacts. That left Intel Mac users without a compatible build in both the alpha feed and stable releases, even though Tauri and Rust can produce `x86_64-apple-darwin` bundles from the same release workflow.
|
||||
|
||||
The updater manifest also needs to distinguish macOS CPU architectures. A generic `darwin` or macOS-only entry would make it too easy for an Intel installation to see an Apple Silicon updater bundle, and browser user agents cannot reliably tell Apple Silicon Macs apart from Intel Macs.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria publishes macOS release artifacts for both Apple Silicon (`darwin-aarch64`) and Intel (`darwin-x86_64`) in every alpha and stable release.**
|
||||
|
||||
- Alpha and stable workflows build the macOS matrix for `aarch64-apple-darwin` and `x86_64-apple-darwin`.
|
||||
- Alpha manifests include signed updater tarballs for `darwin-aarch64` and `darwin-x86_64`.
|
||||
- Stable manifests include both macOS updater tarballs and both manual DMG downloads, alongside the existing Windows x64 and Linux x86_64 entries.
|
||||
- Release jobs normalize macOS artifact filenames with the architecture suffix before publishing so GitHub release assets stay unambiguous.
|
||||
- The stable download page exposes separate Apple Silicon and Intel Mac links. When both Mac links exist, a generic macOS browser is not auto-redirected because user-agent architecture detection is unreliable.
|
||||
- The cross-platform filename portability decisions from ADR-0080 remain in force.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Publish separate Apple Silicon and Intel Mac artifacts** (chosen): gives each updater client an architecture-specific manifest key and gives users explicit manual download links. Cons: doubles the macOS release matrix and signing/notarization surface.
|
||||
- **Publish a universal macOS binary**: gives users one download, but requires lipo/re-sign/notarize coordination and reintroduces the artifact-combining complexity the release pipeline intentionally avoided.
|
||||
- **Keep Apple Silicon-only macOS releases**: keeps CI cheaper, but leaves Intel Mac users unsupported and makes the release artifacts inconsistent with Tolaria's desktop support goals.
|
||||
|
||||
## Consequences
|
||||
|
||||
- macOS release jobs now run one matrix entry per CPU architecture.
|
||||
- Release manifest consumers must treat `darwin-aarch64` and `darwin-x86_64` as distinct platform keys.
|
||||
- Stable manual downloads show two Mac choices instead of pretending browser detection can select the right CPU architecture.
|
||||
- Future macOS release changes must validate both updater and manual-download artifacts for both architectures.
|
||||
35
docs/adr/0084-app-localization-foundation.md
Normal file
35
docs/adr/0084-app-localization-foundation.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0084"
|
||||
title: "App-owned localization foundation"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria was effectively English-only. Users requested a general i18n foundation and Chinese-language support. We need a path that lets the UI adopt additional locales without pushing UI-language preferences into vault files or making every partially translated string a runtime failure.
|
||||
|
||||
## Decision
|
||||
|
||||
Tolaria owns a dependency-free frontend localization layer in `src/lib/i18n.ts`.
|
||||
|
||||
- English is the canonical fallback locale.
|
||||
- Simplified Chinese (`zh-Hans`) is the first additional locale.
|
||||
- `ui_language` is an installation-local app setting in `~/.config/com.tolaria.app/settings.json`; `null` means "follow system language when supported, otherwise English".
|
||||
- Missing translation keys fall back to English.
|
||||
- App-level chrome receives locale through props from `App.tsx`, following the existing props-down/callbacks-up architecture instead of introducing global React context.
|
||||
- Language switching is exposed in Settings and through command-palette actions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Add an i18n dependency now**: useful long term, but unnecessary for the first locale and would add framework surface before we know Tolaria's locale workflow.
|
||||
- **Store language in the vault**: rejected because UI language is an installation preference, not content structure.
|
||||
- **Translate ad hoc strings inline**: rejected because it would make fallback behavior inconsistent and future locales expensive.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New UI strings should be added to `src/lib/i18n.ts` first and rendered through `translate()` / `createTranslator()` where the surface already receives locale.
|
||||
- Partially translated locales remain usable because English is the fallback for missing keys.
|
||||
- Locale choice changes UI chrome immediately after settings save or command-palette language commands without reopening the vault.
|
||||
- Larger feature surfaces can migrate to the shared localization module incrementally.
|
||||
39
docs/adr/0085-non-git-vault-support.md
Normal file
39
docs/adr/0085-non-git-vault-support.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0085"
|
||||
title: "Non-git vaults open with explicit later Git initialization"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
supersedes: "0034"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0034 made Git a hard prerequisite for opening a vault because Git-backed cache, history, change, and sync flows failed invisibly when users opened plain Markdown folders. That protected Git features, but it blocked the common adoption path of opening an existing folder from Obsidian, iCloud, Dropbox, or a manually maintained notes directory.
|
||||
|
||||
Tolaria now needs the opposite default: browsing and editing Markdown should work immediately, while Git remains an explicit capability users can enable when they want history, sync, commits, or collaboration.
|
||||
|
||||
## Decision
|
||||
|
||||
**Open existing Markdown folders even when they are not Git repositories.** A non-git vault is a supported state, not an error state. On open, Tolaria asks whether to initialize Git; if the user dismisses the prompt, the app keeps working and the status bar permanently shows a `Git disabled` warning. Clicking that warning, or running `Initialize Git for Current Vault` from the command palette, reopens the setup action.
|
||||
|
||||
While a vault is not Git-backed:
|
||||
|
||||
- Git history, change, commit, sync, conflict, and remote actions are hidden or disabled.
|
||||
- Background auto-sync and AutoGit checkpoints do not run.
|
||||
- Markdown scanning, note browsing, note editing, search, and non-Git vault features continue normally.
|
||||
|
||||
`init_git_repo` remains the single backend command for enabling Git later. It creates the repository, writes Tolaria's default `.gitignore`, stages the vault, and creates the unsigned setup commit.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A (chosen): Supported non-git mode with explicit later initialization.** Best adoption path; keeps Git capabilities visible without blocking the basic notes workflow.
|
||||
- **Option B: Keep the ADR-0034 blocking modal.** Prevents Git feature ambiguity, but rejects valid plain-folder workflows.
|
||||
- **Option C: Auto-initialize Git when opening a plain folder.** Low friction, but surprising for users who do not want Tolaria to mutate folder metadata.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Existing Git-backed vaults keep the same history, commit, sync, and remote behavior.
|
||||
- UI surfaces must treat Git capability as stateful per vault, not as an app-wide invariant.
|
||||
- Tests need to cover both Git-backed and non-git vaults in browser mocks and native QA.
|
||||
- Future Git-dependent features must check the current vault's Git state before registering commands or running background work.
|
||||
32
docs/adr/0086-in-app-image-file-preview.md
Normal file
32
docs/adr/0086-in-app-image-file-preview.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0086"
|
||||
title: "In-app image previews for binary vault files"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
supersedes: "0041"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0041 made the vault scanner index all visible files and introduced `fileKind` as `"markdown"`, `"text"`, or `"binary"`. Binary files were deliberately shown as inert entries until Tolaria had a dedicated preview model.
|
||||
|
||||
That made image references visible in folder views, but opening an image still felt outside the normal Tolaria workflow. Users need to inspect screenshots, diagrams, and other image assets while keeping their place in the vault.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria previews supported image files in the editor pane while keeping them as ordinary binary `VaultEntry` files.**
|
||||
|
||||
- The scanner keeps the existing `fileKind: "binary"` representation. Image previewability is inferred in the renderer from the file extension, not by introducing a proprietary image document type.
|
||||
- Opening a binary entry creates the same single active-tab state used for notes, but with empty content and no `get_note_content` text read.
|
||||
- `FilePreview` renders supported image extensions through Tauri's asset protocol (`convertFileSrc`) so the original file remains on disk.
|
||||
- Broken images and unsupported binary files render an explicit fallback state with an intentional "Open in default app" action instead of launching another app automatically.
|
||||
- Note-list rows use an image indicator for previewable image binaries. Unsupported binary rows remain muted and non-clickable in the normal list surface.
|
||||
- The preview surface is keyboard focusable and `Escape` returns focus to the note list, matching the app's keyboard-first navigation model.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The existing `VaultEntry` model and cache version do not need to change.
|
||||
- Supported image files can participate in normal selection/navigation context without being converted into Markdown notes.
|
||||
- Unsupported/broken binary files have a clear in-app state when reached through navigation paths that can select them.
|
||||
- Any future PDF, audio, or video preview should extend the same file-preview renderer rather than adding new vault-owned document representations.
|
||||
39
docs/adr/0087-json-catalogs-and-lara-cli-localization.md
Normal file
39
docs/adr/0087-json-catalogs-and-lara-cli-localization.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0087"
|
||||
title: "JSON locale catalogs with Lara CLI synchronization"
|
||||
status: active
|
||||
date: 2026-04-27
|
||||
supersedes:
|
||||
- "0084"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0084 established an app-owned localization layer in `src/lib/i18n.ts` with English fallback and hand-maintained TypeScript dictionaries. That was enough for the first localized UI surface, but it does not scale well to a broader locale matrix or machine-assisted translation workflows.
|
||||
|
||||
We now want Tolaria to support a wider set of locales and to automate translation updates with Lara CLI while keeping the runtime dependency-light and preserving the existing English fallback behavior.
|
||||
|
||||
## Decision
|
||||
|
||||
Tolaria will keep its app-owned runtime localization layer, but the translation source-of-truth moves to flat JSON catalogs in `src/lib/locales/`.
|
||||
|
||||
- `src/lib/locales/en.json` is the canonical source catalog.
|
||||
- Additional locale files use one JSON file per locale code (for example `zh-CN.json`, `fr-FR.json`).
|
||||
- `src/lib/i18n.ts` keeps fallback, interpolation, locale resolution, and props-down locale wiring, but it now loads locale catalogs from JSON files instead of TypeScript objects.
|
||||
- Lara CLI configuration lives in `lara.yaml`, and translation runs happen through repo scripts (`pnpm l10n:translate`, `pnpm l10n:translate:force`).
|
||||
- `scripts/validate-locales.mjs` verifies that every locale catalog present in the repo matches the English keyset and only contains flat string values.
|
||||
- Legacy stored preferences such as `zh-Hans` are normalized to the canonical `zh-CN` locale.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep TypeScript dictionaries and point Lara at `.ts` files**: possible, but JSON is the more standard interchange format for translation tooling and keeps diffs simpler for translators and reviewers.
|
||||
- **Adopt a full frontend i18n framework now**: rejected because Tolaria already has working locale propagation and fallback behavior, and the immediate need is better content management plus translation automation.
|
||||
- **Store translated strings outside the app repo**: rejected because Tolaria's chrome localization should stay versioned with the app code that consumes it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Translators and automation tools now work against plain JSON catalogs instead of editing source code.
|
||||
- The runtime keeps English fallback behavior, so a missing locale file or missing key does not break app chrome.
|
||||
- Locale additions become a data/config change first: add the locale metadata, run Lara, review JSON output, then ship.
|
||||
- Localization work now has a dedicated validation step that can run in CI or before commit.
|
||||
40
docs/adr/0088-markdown-durable-mermaid-diagrams.md
Normal file
40
docs/adr/0088-markdown-durable-mermaid-diagrams.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0088"
|
||||
title: "Markdown-durable Mermaid diagrams in notes"
|
||||
status: active
|
||||
date: 2026-04-27
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria notes are plain Markdown files, while the rich editor uses BlockNote and raw mode uses CodeMirror. Users need fenced `mermaid` blocks to render as diagrams in the note surface without changing the canonical file format or hiding the source from raw editing.
|
||||
|
||||
BlockNote can parse fenced code blocks, but a generic highlighted code block does not provide diagram rendering. Rendering Mermaid directly from the Markdown fence also has to preserve the original fence source when notes are saved, copied through raw mode, closed, and reopened.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria will support Mermaid diagrams through a Markdown placeholder round-trip owned by the editor pipeline and rendered with the `mermaid` package.**
|
||||
|
||||
The implementation:
|
||||
|
||||
- Converts fenced `mermaid` blocks to temporary placeholders before BlockNote parses Markdown.
|
||||
- Replaces placeholders with a `mermaidBlock` schema block that stores both the original fenced source and the diagram body.
|
||||
- Renders the block through Mermaid in the rich editor.
|
||||
- Serializes `mermaidBlock` nodes back to their stored fenced Markdown before save, raw-mode entry, and editor-position snapshots.
|
||||
- Shows the original source as an inline fallback when Mermaid cannot render a diagram.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Tolaria-owned placeholder round-trip with Mermaid rendering** (chosen): matches the existing wikilink and math architecture, keeps Markdown as the source of truth, and gives Tolaria explicit control over serialization.
|
||||
- **Render all `mermaid` code blocks by overriding the generic code-block renderer**: smaller surface, but it couples diagram behavior to the code-highlighting package and makes exact source preservation harder.
|
||||
- **Raw-mode-only Mermaid support**: preserves source but fails the enhanced note reading experience users expect.
|
||||
- **Store parsed diagram metadata outside the Markdown body**: enables richer future editing, but violates the files-first model.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `src/utils/mermaidMarkdown.ts` is the canonical parser/serializer bridge for note diagrams.
|
||||
- Rich mode renders diagrams as schema-backed blocks; raw mode remains the direct source editor.
|
||||
- Invalid Mermaid source remains visible instead of breaking the editor surface.
|
||||
- `mermaid` is now a runtime dependency and should be upgraded deliberately with rendering regression coverage.
|
||||
- Future diagram controls, such as copy source or expand, can attach to the same `mermaidBlock` without changing storage.
|
||||
36
docs/adr/0089-active-vault-filesystem-watcher.md
Normal file
36
docs/adr/0089-active-vault-filesystem-watcher.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0089"
|
||||
title: "Active vault filesystem watcher"
|
||||
status: active
|
||||
date: 2026-04-27
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria treats the filesystem as the source of truth, but before this decision the running app only noticed external file changes after a manual Reload Vault, a Git pull, or an AI-agent-specific refresh callback. Edits from another editor, terminal commands, another Tolaria window, or a non-pull Git operation could leave React state and the editor surface stale.
|
||||
|
||||
ADR-0071 already defines the safe reconciliation policy for external vault mutations: reload vault-derived state, protect unsaved local edits, and reopen the clean active note from disk when needed. Filesystem watching needed to reuse that policy instead of adding another ad hoc reload path.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria watches the active desktop vault with a native filesystem watcher and routes external change batches through the shared external-refresh reconciler.**
|
||||
|
||||
The desktop backend exposes `start_vault_watcher` and `stop_vault_watcher` commands backed by Rust `notify`. It watches the active vault recursively, ignores known non-content churn such as `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`, then emits `vault-changed` events with the active vault path and changed paths.
|
||||
|
||||
The renderer owns batching and reconciliation. `useVaultWatcher` starts the backend watcher for the active main-window vault, debounces native events into one refresh, filters out recent app-owned saves, and calls `refreshPulledVaultState()`. Manual Reload Vault still uses `reload_vault` directly, but now exposes visible reload feedback.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Native active-vault watcher plus shared reconciliation** (chosen): keeps external changes visible without polling and preserves ADR-0071 behavior for clean and unsaved tabs. Cons: adds one native dependency and a long-lived watcher state.
|
||||
- **Frontend-only polling**: simpler backend surface, but wastes work on idle vaults and still needs careful active-note reconciliation.
|
||||
- **Direct `reloadVault()` on every native event**: easy to implement, but bypasses clean-tab reopen handling and can clobber the user experience around unsaved edits.
|
||||
- **Watch every configured vault**: could pre-warm state, but burns resources for inactive vaults and complicates event ownership across windows.
|
||||
|
||||
## Consequences
|
||||
|
||||
- External writes converge automatically into the visible vault state after a short debounce.
|
||||
- Active clean notes are refreshed through the same path as pull and AI-agent updates; unsaved local edits remain protected.
|
||||
- Tolaria app-owned saves are suppressed briefly so autosave does not trigger a full external refresh loop.
|
||||
- The status bar can show reload progress for manual and automatic refreshes.
|
||||
- The watcher is a desktop-only integration; mobile builds keep no-op command stubs until a mobile-specific filesystem strategy exists.
|
||||
35
docs/adr/0090-pi-cli-agent-adapter.md
Normal file
35
docs/adr/0090-pi-cli-agent-adapter.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0090"
|
||||
title: "Pi CLI agent adapter"
|
||||
status: active
|
||||
date: 2026-04-28
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria already supports Claude Code, Codex, and OpenCode as local CLI agents in the AI panel. The next provider request is Pi Coding Agent support with the same first-class availability, settings, status, streaming, and MCP vault access.
|
||||
|
||||
Pi exposes non-interactive JSON events through `pi --mode json`, but Pi core intentionally does not include built-in MCP. Its supported MCP path is the `pi-mcp-adapter` extension, which reads MCP server definitions from Pi-compatible config files.
|
||||
|
||||
## Decision
|
||||
|
||||
Tolaria adds Pi as a supported CLI agent id (`pi`) and launches app-managed Pi sessions through a dedicated adapter module.
|
||||
|
||||
The adapter runs `pi --mode json --no-session` from the active vault cwd, closes stdin, and points `PI_CODING_AGENT_DIR` at a temporary directory containing Tolaria's `mcp.json`. That config loads the Tolaria MCP server through `pi-mcp-adapter`, pins `VAULT_PATH` to the selected vault, sets `WS_UI_PORT=9711`, uses lazy server lifecycle, and exposes the small Tolaria tool set directly.
|
||||
|
||||
Pi availability follows the existing desktop pattern: check the inherited `PATH`, the user's login shell, and common local/toolchain install locations. Pi authentication remains owned by the Pi CLI; Tolaria only surfaces setup errors and does not store provider API keys.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Transient Pi adapter config** (chosen): gives app-launched Pi sessions Tolaria MCP access without mutating user or vault config files. Cons: requires the Pi MCP adapter extension path to be available to Pi.
|
||||
- **Write `.mcp.json` into the active vault**: simple for Pi discovery, but creates project files as a side effect of a chat session and can dirty user vaults.
|
||||
- **Rely on global `~/.pi/agent/mcp.json`**: matches Pi documentation, but silently retargets or depends on user-global state and conflicts with Tolaria's explicit integration boundary.
|
||||
- **Skip MCP for Pi**: easier to implement, but creates a weaker agent than the existing providers and violates the requirement for first-class MCP support.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The AI panel, settings, command palette, status bar, onboarding, and stream normalization now treat Pi as a first-class supported agent.
|
||||
- App-launched Pi sessions can use Tolaria vault MCP tools while remaining scoped to the selected vault.
|
||||
- Pi support stays isolated in Pi-specific modules instead of expanding the shared `ai_agents.rs` hotspot.
|
||||
- Users still need Pi itself, a configured Pi model/provider, and the Pi MCP adapter extension path available to the Pi CLI.
|
||||
35
docs/adr/0091-gemini-cli-external-ai-setup.md
Normal file
35
docs/adr/0091-gemini-cli-external-ai-setup.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0091"
|
||||
title: "Gemini CLI external AI setup"
|
||||
status: active
|
||||
date: 2026-04-28
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria already supports explicit MCP setup for external desktop AI tools. Users asked for Gemini CLI support so the same active-vault MCP server can be registered where Gemini reads tool configuration, with optional Gemini-specific vault guidance.
|
||||
|
||||
Gemini CLI reads MCP server definitions from `~/.gemini/settings.json` and can load project guidance from `GEMINI.md`. Tolaria must support those conventions without silently rewriting global user settings or overwriting user-authored vault instructions.
|
||||
|
||||
## Decision
|
||||
|
||||
Tolaria adds `~/.gemini/settings.json` to the explicit external AI setup path list. The existing MCP entry shape is reused: `mcpServers.tolaria` runs the packaged stdio server through Node.js, pins `VAULT_PATH` to the selected vault, and sets `WS_UI_PORT=9711`.
|
||||
|
||||
Vault guidance keeps `AGENTS.md` as the canonical shared source. `restore_vault_ai_guidance` can create or repair a managed `GEMINI.md` shim that imports `AGENTS.md`, while bootstrap and repair flows continue to seed only required Tolaria guidance (`AGENTS.md` and `CLAUDE.md`) plus type scaffolding. Custom `GEMINI.md` files are classified as custom and are not overwritten.
|
||||
|
||||
The setup dialog documents that Gemini CLI still needs its own install and sign-in. Tolaria does not store Gemini credentials or model-provider API keys.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Add Gemini to explicit MCP setup and optional guidance restore** (chosen): matches the existing consent boundary, preserves user settings, and gives Gemini the shared vault context.
|
||||
- **Generate `GEMINI.md` automatically for every vault**: simpler discovery, but turns an optional third-party compatibility file into startup side effect and dirties vaults for users who do not use Gemini.
|
||||
- **Document manual Gemini setup only**: avoids code changes, but leaves users to transpose paths and loses the active-vault safety already available for other MCP clients.
|
||||
- **Create a Gemini-specific MCP entry shape**: unnecessary because Gemini accepts the same `mcpServers` entry structure used by the existing Tolaria MCP server.
|
||||
|
||||
## Consequences
|
||||
|
||||
- External AI setup now writes/removes Tolaria's MCP entry in Claude, Gemini, Cursor, and generic MCP config files.
|
||||
- Gemini users can create a managed `GEMINI.md` shim without duplicating the canonical `AGENTS.md` guidance.
|
||||
- Existing vault bootstrap and repair remain non-invasive for users who do not use Gemini.
|
||||
- Native QA requires a local Gemini CLI install and authentication for an end-to-end Gemini prompt; otherwise the app can still verify the generated config and documentation paths.
|
||||
39
docs/adr/0092-vault-ai-agent-permission-modes.md
Normal file
39
docs/adr/0092-vault-ai-agent-permission-modes.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0092"
|
||||
title: "Vault-scoped AI agent permission modes"
|
||||
status: active
|
||||
date: 2026-04-28
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0074 established explicit setup and least-privilege defaults for desktop AI tools. The in-app AI panel now supports multiple local CLI agents, and users need a clear per-vault way to choose whether an agent should stay in the narrow vault-safe profile or use broader local-work tools for that vault.
|
||||
|
||||
The mode must be visible at the point of use, must not mutate global CLI settings, and must not silently restore dangerous bypass flags. Existing transcripts should remain intact when the mode changes because a change applies to the next agent run, not to a process that is already streaming.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria stores an `ai_agent_permission_mode` per vault with values `safe` and `power_user`, defaulting missing or null values to `safe`, and passes that normalized mode through the AI panel stream request into each CLI adapter.**
|
||||
|
||||
The AI panel header displays the current mode and offers a compact Vault Safe / Power User control that is disabled while an agent run is active. Changing the mode preserves the transcript and inserts a local transcript marker.
|
||||
|
||||
Adapter mappings remain conservative:
|
||||
- Claude Code Safe keeps `acceptEdits`, strict Tolaria MCP config, and file/search/edit tools only; Power User adds Bash to the allowed tool list without using `--dangerously-skip-permissions`.
|
||||
- Codex keeps the active-vault `workspace-write` sandbox and `--ask-for-approval never` in both modes.
|
||||
- OpenCode uses transient `OPENCODE_CONFIG_CONTENT`; Safe denies bash and external directories, while Power User allows bash but still denies external directories.
|
||||
- Pi receives the mode on the adapter request path; both modes currently use the same transient MCP adapter config.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Per-vault Safe / Power User modes** (chosen): makes the permission surface explicit where the agent is used and preserves least-privilege defaults for each vault.
|
||||
- **Global app setting**: simpler storage, but a single toggle can over-apply a power-user profile to unrelated vaults.
|
||||
- **Dangerous bypass mode**: maximizes CLI freedom, but violates ADR-0074's least-privilege boundary and needs a separate explicit security decision.
|
||||
- **Adapter-specific UI switches**: exposes too much implementation detail and makes cross-agent behavior harder to reason about.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Vault config normalization owns the safe default for old vaults and malformed values.
|
||||
- Agent requests now carry a permission mode through frontend and Rust boundaries, so new adapters must choose an explicit mapping.
|
||||
- Power User is intentionally not equivalent across agents; where an adapter lacks a safe broader local-work switch, both modes may map to the same conservative behavior and must document that with tests.
|
||||
- Any future dangerous mode requires a new ADR and separate UI language.
|
||||
35
docs/adr/0093-shared-cli-agent-runtime-adapters.md
Normal file
35
docs/adr/0093-shared-cli-agent-runtime-adapters.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0093"
|
||||
title: "Shared CLI agent runtime adapters"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria supports Claude Code, Codex, OpenCode, and Pi as local CLI agents in the AI panel. Each agent has different command-line arguments, configuration shape, and JSON event schema, but the Rust backend had grown repeated runtime plumbing around those differences: request shapes, prompt wrapping, subprocess launch, stdout JSON reading, stderr capture, exit handling, done events, version probing, and Tolaria MCP server path resolution.
|
||||
|
||||
That duplication made small runtime fixes expensive because they had to be repeated across several adapter files. It also kept Codex-specific command and event mapping inside `ai_agents.rs`, making the top-level module both an orchestrator and a bespoke adapter.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria uses `cli_agent_runtime.rs` as the shared runtime scaffold for app-managed CLI agents, while `ai_agents.rs` only normalizes and dispatches requests to per-agent adapter modules.**
|
||||
|
||||
The shared scaffold owns the common agent request shape, system/user prompt wrapping, JSON-line process lifecycle, normalized error/done handling for `AiAgentStreamEvent` adapters, version probing, and Tolaria MCP server path resolution. Per-agent modules keep the provider-specific pieces: binary discovery candidates, command arguments, transient config shape, authentication error wording, and JSON event mapping.
|
||||
|
||||
Codex now lives in `codex_cli.rs`, matching the Claude, OpenCode, and Pi adapter boundary. `ai_agents.rs` remains the Tauri-facing orchestrator that chooses an adapter and maps Claude's legacy event enum into the normalized event stream.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Shared runtime scaffold with thin adapters** (chosen): reduces repeated process lifecycle code without hiding provider-specific command/config/event behavior.
|
||||
- **One trait object per agent**: more uniform on paper, but adds indirection without removing much current complexity.
|
||||
- **Leave each adapter self-contained**: keeps local readability for a single file, but new process, prompt, and MCP fixes continue to land in parallel.
|
||||
- **Fully generic event mapping**: over-abstracts the JSON schemas and makes provider-specific edge cases harder to test.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Runtime lifecycle fixes should usually start in `cli_agent_runtime.rs`.
|
||||
- New agent adapters should use the shared request/prompt/process helpers and keep only command, config, discovery, and event mapping local.
|
||||
- `ai_agents.rs` should not grow provider-specific runtime code again; it should normalize the frontend request, dispatch to an adapter, and map any legacy event shape.
|
||||
- The shared scaffold deliberately does not erase provider differences. Authentication messages, permission semantics, and transient config formats remain adapter-owned and must stay covered by adapter tests.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0094"
|
||||
title: "Gitignored content visibility as a command-boundary filter"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's vault scanner now indexes more of the real filesystem so Folder views, search, and reload flows can reflect what is actually in the vault. In Git-backed vaults, that includes generated, local-only, or machine-specific content that users intentionally hide through `.gitignore`.
|
||||
|
||||
Always showing Gitignored files makes Folder lists and search noisy, especially in vaults that contain exports, build artifacts, or personal local scratch files. But removing those files during scanning would make visibility dependent on cache shape, complicate toggling, and blur the distinction between "what exists in the vault" and "what this installation chooses to surface."
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria keeps the vault scan and cache complete, then applies Gitignored-content visibility at the command boundary before entries, folders, or search results reach React.**
|
||||
|
||||
- `hide_gitignored_files` is an installation-local app setting and defaults to `true`.
|
||||
- Visibility checks use batched `git check-ignore --no-index --stdin` so Tolaria follows normal Git ignore and negation semantics as closely as practical.
|
||||
- `list_vault`, `reload_vault`, `list_vault_folders`, and keyword search all apply the same filter when the setting is enabled.
|
||||
- Toggling the setting reloads the current vault surfaces instead of rebuilding a different cache format.
|
||||
- If a vault has no `.gitignore`, or Gitignored visibility is turned off, Tolaria shows the full scanned result.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Complete scan/cache + boundary filter** (chosen): keeps the filesystem model authoritative, makes toggling cheap and consistent, and avoids cache divergence.
|
||||
- **Skip Gitignored content during scan/cache**: reduces later filtering work, but makes visibility part of the persisted cache shape and complicates instant toggling.
|
||||
- **Always show Gitignored content**: simplest implementation, but too noisy for real Git-backed vaults and undermines users' existing ignore rules.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Gitignored visibility is a per-installation comfort preference, not vault-authored shared metadata.
|
||||
- Search, folder lists, and note reloads stay aligned because they all consult the same boundary filter.
|
||||
- The cache can still support future visibility changes without a data migration.
|
||||
- Users can reveal ignored content again immediately by disabling the setting.
|
||||
- Future features that expose vault file lists should apply the same boundary filter unless they intentionally need raw filesystem output.
|
||||
35
docs/adr/0095-saved-view-order-field.md
Normal file
35
docs/adr/0095-saved-view-order-field.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0095"
|
||||
title: "Saved views use an explicit YAML order field"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Saved Views already persist as user-editable YAML files in the vault and sync through Git. Filename ordering was stable, but it forced users to rename files just to change sidebar order and gave Tolaria no durable way to support drag reordering, move actions, or keyboard-first ordering controls.
|
||||
|
||||
The ordering choice also needs to travel with the view definition itself. Saved Views are part of the vault's shared information architecture, not a machine-local preference.
|
||||
|
||||
## Decision
|
||||
|
||||
**Each Saved View may store an optional top-level `order` number in its YAML definition, and Tolaria sorts views by that value before falling back to filename.**
|
||||
|
||||
- Lower `order` values render earlier in the sidebar and other Saved View lists.
|
||||
- Views without `order` sort after ordered views and then fall back to filename ordering for stability.
|
||||
- Reordering actions rewrite affected view files with a dense sequence of order values instead of encoding position in filenames.
|
||||
- The same persisted order supports drag handles, explicit move buttons, and command-palette ordering actions.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Explicit `order` field in the view YAML** (chosen): portable, Git-syncable, easy to inspect by hand, and consistent with the existing file-first view model.
|
||||
- **Filename-based ordering only**: no schema change, but makes reordering clumsy and couples user-visible structure to file naming.
|
||||
- **App-local ordering state**: easy to prototype, but breaks cross-device consistency and separates ordering from the view artifact users already version.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Saved View ordering becomes part of the vault and syncs naturally through Git.
|
||||
- Existing views remain valid; unordered files keep a stable fallback sort until reordered.
|
||||
- Reordering can touch multiple view files in one action because Tolaria normalizes the sequence.
|
||||
- Future Saved View features should treat `order` as part of the shared YAML schema rather than introducing a parallel ordering store.
|
||||
37
docs/adr/0096-root-created-type-documents.md
Normal file
37
docs/adr/0096-root-created-type-documents.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0096"
|
||||
title: "Root-created type documents"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria identifies type definitions by markdown frontmatter (`type: Type`), not by filesystem location. Older documentation and UI creation flows still treated `type/` as the canonical destination for new type documents, with a compatibility fallback for vaults that already used `types/`.
|
||||
|
||||
That folder-based creation policy conflicted with the broader vault model: notes are scanned from all non-hidden folders, type identity comes from metadata, and root `type.md` / `note.md` definitions are already used by repair and bootstrap flows.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria creates new type documents at the vault root.**
|
||||
|
||||
- A type document is any markdown note with `type: Type` in frontmatter.
|
||||
- New UI-created type documents use `{vault}/{slug}.md`.
|
||||
- Existing type documents in `type/`, `types/`, or other scanned folders remain valid and continue to drive templates, icons, colors, visibility, sorting, and sidebar grouping.
|
||||
- Creation does not silently migrate or move existing type documents.
|
||||
- Root filename collisions are handled as file collisions; Tolaria must not overwrite an existing note when creating a type document.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Root-created type documents** (chosen): matches the metadata-first model, removes special casing from creation, and aligns new types with root-managed default type scaffolding.
|
||||
- **Canonical `type/` folder**: avoids root filename collisions, but makes path special even though type identity is already defined by frontmatter.
|
||||
- **Preserve existing folder convention dynamically**: minimizes change for plural `types/` vaults, but creates inconsistent behavior across vaults and leaves `types/` only partially supported.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Users can inspect and edit type documents as ordinary root notes by default.
|
||||
- Existing vaults with `type/` or `types/` type documents remain readable because vault scanning already includes non-hidden subdirectories.
|
||||
- If a root note with the same slug already exists, type creation fails with a collision message instead of writing into a fallback folder.
|
||||
- Legacy `type/` may remain hidden from the folder tree so old type documents do not duplicate the Types sidebar section.
|
||||
- Re-evaluate if users need a guided migration from folder-based type documents to root type documents.
|
||||
42
docs/adr/0097-gemini-cli-agent-adapter.md
Normal file
42
docs/adr/0097-gemini-cli-agent-adapter.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0097"
|
||||
title: "Gemini CLI agent adapter"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0091 added Gemini CLI to explicit external MCP setup, but Gemini was still absent from Tolaria's selectable app-managed AI agents. That left the AI panel able to generate Gemini-compatible MCP configuration while the actual agent picker, availability checks, install links, and streaming dispatch did not treat Gemini like Claude Code, Codex, OpenCode, or Pi.
|
||||
|
||||
Gemini CLI supports headless `--prompt` execution with JSON output, configurable approval modes, tool exclusion, and settings-file overrides through `GEMINI_CLI_SYSTEM_SETTINGS_PATH`. Those features are enough to launch Gemini from Tolaria without mutating the user's durable `~/.gemini/settings.json` during app-managed sessions.
|
||||
|
||||
## Decision
|
||||
|
||||
Tolaria adds Gemini CLI as a first-class `AiAgentId`. The frontend agent definitions, onboarding prompt, install links, default-agent normalization, status badge, command registry, settings persistence, and mock Tauri status payloads include `gemini`.
|
||||
|
||||
The desktop backend adds a Gemini adapter that:
|
||||
|
||||
- discovers `gemini` through the process path, login shell, and common local/toolchain install locations
|
||||
- runs `gemini --output-format json --approval-mode <mode> --prompt <prompt>` from the active vault
|
||||
- supplies Tolaria MCP through a temporary settings file referenced by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
|
||||
- uses Safe mode with `auto_edit`, an untrusted MCP entry, and `tools.exclude=["run_shell_command"]`
|
||||
- uses Power User mode with `yolo` and a trusted Tolaria MCP entry
|
||||
- maps Gemini JSON responses into Tolaria's existing AI panel stream events
|
||||
|
||||
The existing external MCP setup remains explicit and durable. The app-managed Gemini adapter uses transient settings so selecting Gemini in Tolaria does not rewrite the user's global Gemini config.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Add Gemini as a first-class app-managed agent** (chosen): matches the existing agent picker and onboarding UI, uses Gemini's headless JSON mode, and keeps MCP setup vault-scoped.
|
||||
- **Keep Gemini as external MCP setup only**: avoids another adapter, but keeps the interface inconsistent and requires users to leave Tolaria for a flow that other agents support in-panel.
|
||||
- **Write app-managed Gemini config into `~/.gemini/settings.json`**: reuses the external setup path, but would blur the consent boundary and risk overwriting user preferences during normal AI panel usage.
|
||||
- **Use interactive Gemini sessions**: could preserve richer CLI state, but does not fit Tolaria's current one-request stream lifecycle and would make cleanup/auth/error handling harder.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Gemini appears anywhere users can choose, install, or switch local AI agents.
|
||||
- End-to-end native Gemini QA requires the Gemini CLI to be installed and authenticated, but missing/auth failures now produce agent-specific guidance.
|
||||
- Safe and Power User behavior is limited by Gemini's own approval/tool semantics; if Gemini changes those names, the adapter tests and docs need updating.
|
||||
- The durable MCP setup path and optional `GEMINI.md` shim continue to serve external Gemini usage outside Tolaria's AI panel.
|
||||
29
docs/adr/0098-in-app-image-and-pdf-file-previews.md
Normal file
29
docs/adr/0098-in-app-image-and-pdf-file-previews.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0098"
|
||||
title: "In-app image and PDF previews for binary vault files"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
supersedes: "0086"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0086 introduced the `FilePreview` path for image binaries while keeping binary files as ordinary `VaultEntry` records. The same file-first model should now cover PDFs, because asset-heavy vaults often mix screenshots, diagrams, and document exports that users need to inspect without leaving Tolaria.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria previews supported image and PDF files in the editor pane while keeping them as ordinary binary vault files.**
|
||||
|
||||
- The scanner keeps the coarse `fileKind: "binary"` representation. Previewability stays a renderer concern inferred from the file extension in `src/utils/filePreview.ts`.
|
||||
- Supported images render with `<img>` and supported PDFs render with the webview PDF object renderer, both using Tauri asset URLs from `convertFileSrc`.
|
||||
- The Tauri CSP permits scoped asset URLs in `object-src` so PDF objects can load vault-backed files without broadening script, connect, or image policy.
|
||||
- PDF preview fallback content lives inside the PDF object so unsupported or failed renderers still expose an explicit "Open in default app" escape hatch.
|
||||
- Note-list rows for previewable images and PDFs remain clickable and carry file-specific indicators; unsupported binary rows stay muted and non-clickable.
|
||||
- `Escape` on the preview surface returns keyboard focus to the note list, matching the existing image-preview keyboard behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
- PDFs do not become notes and do not get Markdown editor semantics.
|
||||
- The asset preview surface can keep growing to additional safe binary formats without changing the vault scanner or persisted cache shape.
|
||||
- Broken PDFs may rely on the webview's own renderer failure state, but the surrounding Tolaria preview chrome still provides reveal, copy path, and default-app actions.
|
||||
37
docs/adr/0099-cumulative-vault-asset-scope.md
Normal file
37
docs/adr/0099-cumulative-vault-asset-scope.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0099"
|
||||
title: "Cumulative vault asset scope for previews"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
supersedes: "0074 asset-protocol runtime scoping"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0074 moved the desktop asset protocol away from broad filesystem access and toward runtime vault scoping. The implementation tried to keep only the active vault in scope by calling Tauri's `forbid_directory` for vault roots that were no longer active.
|
||||
|
||||
Tauri's filesystem scope treats forbidden paths as permanent precedence rules: a forbidden path is denied even if it is later allowed again. After a user switched away from a vault and back, image and PDF previews could keep producing `403 Forbidden` responses for valid vault files until the app restarted.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria accumulates Tauri asset protocol access for vault roots loaded during the current app session and never forbids a previously loaded vault root at runtime.**
|
||||
|
||||
- `sync_vault_asset_scope` adds the canonical vault root and requested vault root when they are missing from the runtime asset scope.
|
||||
- The runtime asset scope remains narrower than global filesystem access because only vault roots that Tolaria has loaded are added.
|
||||
- Command paths still enforce the active vault boundary through the Rust command layer before reads, writes, external opens, and attachment imports.
|
||||
- Asset scope revocation is deferred to process exit, because Tauri does not expose a safe runtime unallow operation for directories.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Cumulative runtime vault scope** (chosen): keeps previews reliable after vault switches while preserving vault-only access in the current process.
|
||||
- **Continue forbidding previous vaults**: appears stricter, but Tauri forbids are not reversible and valid previews fail after switching back.
|
||||
- **Allow all filesystem paths**: avoids preview failures but returns to the broad asset protocol access that ADR-0074 intentionally removed.
|
||||
- **Replace `convertFileSrc` with a custom protocol**: could support exact active-vault revocation, but it would be a larger cross-cutting migration for editor images, file previews, and PDF rendering.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Images and PDFs from any vault loaded in the current session can keep rendering after vault switches.
|
||||
- The app process, not each vault switch, is the revocation boundary for asset URL access.
|
||||
- Active-vault command validation remains the primary guard for mutations and default-app opens.
|
||||
- Re-evaluate this if Tauri adds a public runtime unallow operation for asset protocol directories.
|
||||
@@ -68,7 +68,7 @@ proposed → active → superseded
|
||||
| [0010](0010-dynamic-wikilink-relationship-detection.md) | Dynamic wikilink relationship detection | active |
|
||||
| [0011](0011-mcp-server-for-ai-integration.md) | MCP server for AI tool integration | superseded → [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) |
|
||||
| [0012](0012-claude-cli-for-ai-agent.md) | Claude CLI subprocess for AI agent | active |
|
||||
| [0013](0013-remove-theming-system.md) | Remove vault-based theming system | active |
|
||||
| [0013](0013-remove-theming-system.md) | Remove vault-based theming system | superseded -> [0081](0081-internal-light-dark-theme-runtime.md) |
|
||||
| [0014](0014-git-based-vault-cache.md) | Git-based incremental vault cache | active |
|
||||
| [0015](0015-auto-save-with-debounce.md) | Auto-save with 500ms debounce | active |
|
||||
| [0016](0016-sentry-posthog-telemetry.md) | Sentry + PostHog telemetry with consent | active |
|
||||
@@ -89,7 +89,7 @@ proposed → active → superseded
|
||||
| [0031](0031-full-app-for-note-windows.md) | Full App instance for secondary note windows | active |
|
||||
| [0032](0032-status-bar-for-git-actions.md) | Git actions (Changes, Pulse, Commit) in status bar, not sidebar | active |
|
||||
| [0033](0033-subfolder-scanning-and-folder-tree.md) | Subfolder scanning and folder tree navigation | active |
|
||||
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | active |
|
||||
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | superseded → [0085](0085-non-git-vault-support.md) |
|
||||
| [0035](0035-path-suffix-wikilink-resolution.md) | Path-suffix wikilink resolution for subfolder vaults | active |
|
||||
| [0036](0036-external-rename-detection-via-git-diff.md) | External rename detection via git diff on focus regain | active |
|
||||
| [0037](0037-codemirror-language-markdown-highlighting.md) | Language-based markdown syntax highlighting in raw editor | active |
|
||||
@@ -132,3 +132,23 @@ proposed → active → superseded
|
||||
| [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) | Explicit external AI tool setup and least-privilege desktop scope | active |
|
||||
| [0075](0075-crash-safe-note-rename-transactions.md) | Crash-safe note rename transactions | active |
|
||||
| [0076](0076-note-retargeting-separates-type-and-folder-moves.md) | Note retargeting separates type changes from folder moves | active |
|
||||
| [0077](0077-concurrent-safe-vault-cache-replacement.md) | Concurrent-safe vault cache replacement | active |
|
||||
| [0078](0078-scoped-unsigned-fallback-for-app-managed-git-commits.md) | Scoped unsigned fallback for app-managed git commits | active |
|
||||
| [0079](0079-linux-window-chrome-and-menu-reuse.md) | Linux window chrome and menu reuse | active |
|
||||
| [0080](0080-cross-platform-desktop-release-artifacts-and-portable-vault-names.md) | Cross-platform desktop release artifacts and portable vault names | superseded → [0083](0083-dual-architecture-macos-release-artifacts.md) |
|
||||
| [0081](0081-internal-light-dark-theme-runtime.md) | Internal light and dark theme runtime | active |
|
||||
| [0082](0082-markdown-durable-math-notes.md) | Markdown-durable math in notes | active |
|
||||
| [0083](0083-dual-architecture-macos-release-artifacts.md) | Dual-architecture macOS release artifacts | active |
|
||||
| [0085](0085-non-git-vault-support.md) | Non-git vaults open with explicit later Git initialization | active |
|
||||
| [0088](0088-markdown-durable-mermaid-diagrams.md) | Markdown-durable Mermaid diagrams in notes | active |
|
||||
| [0089](0089-active-vault-filesystem-watcher.md) | Active vault filesystem watcher | active |
|
||||
| [0090](0090-pi-cli-agent-adapter.md) | Pi CLI agent adapter | active |
|
||||
| [0091](0091-gemini-cli-external-ai-setup.md) | Gemini CLI external AI setup | active |
|
||||
| [0092](0092-vault-ai-agent-permission-modes.md) | Vault-scoped AI agent permission modes | active |
|
||||
| [0093](0093-shared-cli-agent-runtime-adapters.md) | Shared CLI agent runtime adapters | active |
|
||||
| [0094](0094-gitignored-content-visibility-boundary-filter.md) | Gitignored content visibility as a command-boundary filter | active |
|
||||
| [0095](0095-saved-view-order-field.md) | Saved views use an explicit YAML order field | active |
|
||||
| [0096](0096-root-created-type-documents.md) | Root-created type documents | active |
|
||||
| [0097](0097-gemini-cli-agent-adapter.md) | Gemini CLI agent adapter | active |
|
||||
| [0098](0098-in-app-image-and-pdf-file-previews.md) | In-app image and PDF previews for binary vault files | active |
|
||||
| [0099](0099-cumulative-vault-asset-scope.md) | Cumulative vault asset scope for previews | active |
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVault,
|
||||
removeFixtureVaultCopy,
|
||||
} from '../tests/helpers/fixtureVault'
|
||||
|
||||
// Minimal valid PNG: 1x1 red pixel
|
||||
const TEST_PNG_BASE64 =
|
||||
@@ -11,16 +16,28 @@ function createTestPng(filepath: string) {
|
||||
fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64'))
|
||||
}
|
||||
|
||||
test('image upload via file picker displays image with data URL', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(1000)
|
||||
let tempVaultDir: string
|
||||
|
||||
// Open a note
|
||||
await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 })
|
||||
await page.waitForTimeout(500)
|
||||
async function openImageTestNote(page: Page) {
|
||||
await page.locator('[data-testid="note-list-container"]').getByText('Alpha Project', { exact: true }).click()
|
||||
|
||||
const editor = page.locator('.bn-editor')
|
||||
await expect(editor).toBeVisible({ timeout: 10000 })
|
||||
return editor
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(60_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('image upload via file picker displays image with data URL', async ({ page }) => {
|
||||
const editor = await openImageTestNote(page)
|
||||
await editor.click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
@@ -63,16 +80,37 @@ test('image upload via file picker displays image with data URL', async ({ page
|
||||
if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath)
|
||||
})
|
||||
|
||||
test('image paste into editor inserts image block', async ({ page }) => {
|
||||
const editor = await openImageTestNote(page)
|
||||
await editor.click()
|
||||
|
||||
await page.evaluate((base64) => {
|
||||
const editorElement = document.querySelector('.bn-editor')
|
||||
if (!editorElement) throw new Error('Editor not found')
|
||||
|
||||
const binary = atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
|
||||
const file = new File([bytes], 'pasted-image.png', { type: 'image/png' })
|
||||
const clipboardData = new DataTransfer()
|
||||
clipboardData.items.add(file)
|
||||
editorElement.dispatchEvent(new ClipboardEvent('paste', {
|
||||
clipboardData,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
}, TEST_PNG_BASE64)
|
||||
|
||||
const images = page.locator('.bn-editor img')
|
||||
await expect(images.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const src = await images.first().getAttribute('src')
|
||||
expect(src).toMatch(/^data:/)
|
||||
})
|
||||
|
||||
test('editor has uploadFile configured (no error on image block insert)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Click first note
|
||||
await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const editor = page.locator('.bn-editor')
|
||||
await expect(editor).toBeVisible({ timeout: 10000 })
|
||||
const editor = await openImageTestNote(page)
|
||||
|
||||
// Capture console errors
|
||||
const errors: string[] = []
|
||||
|
||||
45
index.html
45
index.html
@@ -7,17 +7,48 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
background: #FFFFFF;
|
||||
color: #37352F;
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
background: #1F1E1B;
|
||||
color: #E6E1D8;
|
||||
}
|
||||
body {
|
||||
background: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
<title>Tolaria</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// Apply saved theme before React mounts to prevent flash
|
||||
var t = localStorage.getItem('tolaria-theme');
|
||||
if (t === null) {
|
||||
t = localStorage.getItem('laputa-theme');
|
||||
if (t !== null) localStorage.setItem('tolaria-theme', t);
|
||||
}
|
||||
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||||
(function () {
|
||||
var key = 'tolaria-theme';
|
||||
var legacyKey = 'laputa-theme';
|
||||
function normalizeTheme(value) {
|
||||
return value === 'dark' || value === 'light' ? value : null;
|
||||
}
|
||||
function applyTheme(value) {
|
||||
var mode = normalizeTheme(value) || 'light';
|
||||
document.documentElement.setAttribute('data-theme', mode);
|
||||
document.documentElement.classList.toggle('dark', mode === 'dark');
|
||||
}
|
||||
try {
|
||||
var mode = normalizeTheme(localStorage.getItem(key));
|
||||
if (mode === null) {
|
||||
mode = normalizeTheme(localStorage.getItem(legacyKey));
|
||||
if (mode !== null) localStorage.setItem(key, mode);
|
||||
}
|
||||
applyTheme(mode);
|
||||
} catch (err) {
|
||||
applyTheme('light');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
|
||||
491
lara.lock
Normal file
491
lara.lock
Normal file
@@ -0,0 +1,491 @@
|
||||
version: 1.1.0
|
||||
files:
|
||||
e6ab9bf36c30b9d72bc97897e0d89a16:
|
||||
command.noMatches: a2a97d96e196148e04c51c47d985e327
|
||||
command.palettePlaceholder: 6d6b61b4c08ed382ad7ba406a9ba395d
|
||||
command.footerNavigate: e3808db59d29bcfc91532d6539c4f9ae
|
||||
command.footerSelect: 6d73339906ed42e20f6be91bf7f5f4a9
|
||||
command.footerClose: dcf77460e548fe215ef28583f092a496
|
||||
command.footerSend: 6b19fc9694fd8b7b3f1aafaca83db864
|
||||
command.aiMode: 7197e3c5f9afd24022377176611f35cb
|
||||
command.openSettings: 638c05f4f159a403e04aebe94b46a2a8
|
||||
command.openSettings.keywords: ab6b5e03395f4db955ed9cbfd4de33b7
|
||||
command.openLanguageSettings: e5a425aa6222fab3df66c0e1e0ca4183
|
||||
command.openLanguageSettings.keywords: 68efcb7e847b1a9d472baa609824be80
|
||||
command.useSystemLanguage: b7f0315c47d4a59faa2a49571fcf1fca
|
||||
command.openH1Setting: 05b4f6edc0e98b29670e8ee902cdb9bf
|
||||
command.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
|
||||
command.contribute: 9887a4451812854f0f1b6f669a874307
|
||||
command.checkUpdates: f77f38f684c8b974dd4a56bb321cfd5f
|
||||
command.group.navigation: 846495f9ceed11accf8879f555936a7d
|
||||
command.group.note: 3b0649c72650c313a357338dcdfb64ec
|
||||
command.group.git: 0bcc70105ad279503e31fe7b3f47b665
|
||||
command.group.view: 4351cfebe4b61d8aa5efa1d020710005
|
||||
command.group.settings: f4f70727dc34561dfde1a3c529b6205c
|
||||
command.navigation.searchNotes: d8002e888293774162e43b263658cbb5
|
||||
command.navigation.goAllNotes: 7030dcbbb85b6f01d3c8b2fdcb6a5cff
|
||||
command.navigation.goArchived: b620b8cb8fef8a287b986ff83a992a2a
|
||||
command.navigation.goChanges: c6dd094ce55f8a765b68cef40a1c6bd9
|
||||
command.navigation.goHistory: 39b86b661267053a240ffe89b9eca847
|
||||
command.navigation.goBack: 4f2f5e1d6e2a13469ad431474a68ab82
|
||||
command.navigation.goForward: d13c318438f67d749cfc0904e3fafcda
|
||||
command.navigation.goInbox: 522af71c26bc7e3ce1dae6204c2fbe9f
|
||||
command.navigation.renameFolder: a3d3b0f787c52a984af61085c577bff2
|
||||
command.navigation.deleteFolder: 991e322cd8225c369a963bb60b0a9688
|
||||
command.navigation.showOpenNotes: bd1905c340d0518187865f4a6694e14d
|
||||
command.navigation.showArchivedNotes: 241399b908884adfe8608a3ab827ca74
|
||||
command.navigation.listType: 4f70c27c6826a37543c8ada561397c22
|
||||
command.note.newNote: 23c4edda8b815f750782f01870d27271
|
||||
command.note.newType: adce5108f18945cc502a06c02445e32d
|
||||
command.note.newTypedNote: 1493eda772c2179cb6247169d00723b2
|
||||
command.note.saveNote: 2a309cda46e95b00d2a5a8afb3cc0047
|
||||
command.note.findInNote: f72f8f93d8e6119d5d08b60eb3a7b3bc
|
||||
command.note.replaceInNote: b008e2e20c5e4cd202f4386271d6bed1
|
||||
command.note.deleteNote: 56f727a2ee11d15159f1aa6373314cb6
|
||||
command.note.archiveNote: 6043509fda6dd7f6f167e4108033f8e8
|
||||
command.note.unarchiveNote: 6937fb694d00b11d579c21cd4bf103a2
|
||||
command.note.addFavorite: 782d6b28dce4e5d0b2ac92142c6624ba
|
||||
command.note.removeFavorite: 4f9b5fa08f2ef86fdc5d0ceefc271981
|
||||
command.note.markOrganized: 505ad63357f533fb39e4276a2bfb9496
|
||||
command.note.markUnorganized: 1c62f1bffcb793c63d729096d2794ced
|
||||
command.note.restoreDeleted: d704f8283ca322a9b3713d2ccf9a6cd4
|
||||
command.note.setIcon: 99b81d2663266c53e45e4d9c62c87a29
|
||||
command.note.removeIcon: 4bccbad66025e506b37b4218498b299c
|
||||
command.note.changeType: 4a54ffd47ed1f65ee97f34bfe8c3de14
|
||||
command.note.moveToFolder: 3a0f26c42b9168ad839960d134065905
|
||||
command.note.openNewWindow: 6f67b2857093fa0fd45b1122dff4865d
|
||||
command.git.initialize: 54a57cdc7105db6fb22dca661ce01ad6
|
||||
command.git.commitPush: 8cb5faa4019716f43dd69c41496b1d46
|
||||
command.git.addRemote: 7eef951b3f909340a68dc9811be83e9e
|
||||
command.git.pull: 3988d61f4a4546381f61a4eaf61315b4
|
||||
command.git.resolveConflicts: 871ac36968cfca3d2297a89b28b25dee
|
||||
command.git.viewChanges: b2699edf69d8e1031afce2b2f94e5c8d
|
||||
command.view.editorOnly: 8355e056b32086b9190d639be290dda8
|
||||
command.view.editorNoteList: b9604b1609eb6f581cd9570dca72d3f0
|
||||
command.view.fullLayout: 1cebdf839eee2b1713828731aaa3b507
|
||||
command.view.toggleProperties: 54f12756a363401243d82cbd0466117e
|
||||
command.view.toggleDiff: 148a10ef6f04f897e73289f529ecae86
|
||||
command.view.toggleRaw: 9b622257cd6345ceaf58e42b1410899b
|
||||
command.view.noteWidthNormal: d29ab88a55ae178bf4b5257201e51801
|
||||
command.view.noteWidthWide: 403f3913ad71451a90787c235f1684de
|
||||
command.view.defaultNoteWidthNormal: b045349e37329df23317db98cccd3263
|
||||
command.view.defaultNoteWidthWide: 41f326763e3e3954912dfd45d04bd87c
|
||||
command.view.leftLayout: 5046a7166675e2d0175b9da3befdcaca
|
||||
command.view.centerLayout: 3bc6759c005178aae519aa3dd227cb74
|
||||
command.view.toggleAiPanel: 4279cbf31767465f25feff6e7bdd336e
|
||||
command.view.newAiChat: 1b0a886d6e77f628ec96c2d71cdb0a9b
|
||||
command.view.toggleBacklinks: b3c4be2d2c07bb8df181355a2c3658a7
|
||||
command.view.moveViewUp: 76f92d3250e028609349d825c672f13e
|
||||
command.view.moveViewDown: 9deea30162b8d3234f8d52795ad7393f
|
||||
command.view.moveNamedViewUp: b4ac634d13a09eacb680ef12c69dd1f7
|
||||
command.view.moveNamedViewDown: 15302c02df26fe52def77636e9141711
|
||||
command.view.zoomIn: ae4a8e406b707636392b6673fca0e6a6
|
||||
command.view.zoomOut: 97326f8cd9df886a6b19af180fb7dcc9
|
||||
command.view.resetZoom: 193ee8c32391fc5cc303a51617cfd046
|
||||
command.settings.createEmptyVault: d7c9673bb6c62a2b8aef009546ce8b23
|
||||
command.settings.openVault: c40e74822da9417fdf52eb4a37bb23f2
|
||||
command.settings.removeVault: 390e2b2031cde0dd18381efee0923bea
|
||||
command.settings.restoreGettingStarted: b3358dc1c7b873b2a02ac6f05473fe4d
|
||||
command.settings.manageExternalAi: c36b9bd171f11a18a1e624365d2b57ac
|
||||
command.settings.setupExternalAi: 24eea679eb699763daa2e3f2a67fcf9a
|
||||
command.settings.reloadVault: f6e506dad6b6cf2be24d9438d458ae54
|
||||
command.settings.repairVault: cee560b1fd5ad9d1ff1c19a0a2afe77a
|
||||
command.settings.useLightMode: a76d5b1c076983bc113d247f0520c166
|
||||
command.settings.useDarkMode: ba7873c42ae00542ba71db9be3b6761f
|
||||
command.settings.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
|
||||
command.ai.openAgents: 612abe804edd0f5ae228a534f77f3d23
|
||||
command.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2
|
||||
command.ai.switchToAgent: 5bb02187e653b360ab7ed661403271f2
|
||||
command.ai.switchDefault: 42724de254240d598fbcc40fdb5b18a3
|
||||
command.ai.switchDefaultWithAgent: d405f7914bb1cd86172f108f3c33d953
|
||||
settings.title: f4f70727dc34561dfde1a3c529b6205c
|
||||
settings.close: 7812b3644f897c5d3efb0dd63440e751
|
||||
settings.sync.title: 36f62a237420dcdc7096ad3a9018c4d9
|
||||
settings.sync.description: 06a6957c40867be2c28783183dc1bf5f
|
||||
settings.pullInterval: 4f95ca677398604bca241427261ed07b
|
||||
settings.releaseChannel: 2da37055e15a217f18bd403922085c3f
|
||||
settings.releaseStable: fa3aff3c185c6dc7754235f397c2099a
|
||||
settings.releaseAlpha: 6132295fcf5570fb8b0a944ef322a598
|
||||
settings.appearance.title: a1c58e94227389415de133efdf78ea6e
|
||||
settings.appearance.description: ca7b75a4c10ab8a540707e3a96cd3592
|
||||
settings.theme.label: d721757161f7f70c5b0949fdb6ec2c30
|
||||
settings.theme.light: 9914a0ce04a7b7b6a8e39bec55064b82
|
||||
settings.theme.dark: a18366b217ebf811ad1886e4f4f865b2
|
||||
settings.language.title: 4994a8ffeba4ac3140beb89e8d41f174
|
||||
settings.language.description: e2bb9b523067fdc32a494b1d6fd97906
|
||||
settings.language.label: 244c7b77926f077b863c33ff1244e1ec
|
||||
settings.language.system: 41e2252344aa441e925589ddcb762e6d
|
||||
settings.language.summary: edc83f1f48ce29bc80bf2f2f90e24997
|
||||
settings.autogit.title: e59f720605ec1cfa42b3b97d89d7e883
|
||||
settings.autogit.description.enabled: e65bec70ca4d7921be56f84d10836018
|
||||
settings.autogit.description.disabled: 1143ef6ee00614816785e3c6fc299d51
|
||||
settings.autogit.enable: 9b205a4ae0e3ad4e6708155880ce9c75
|
||||
settings.autogit.enableDescription: a7424a8f90c0b7f290a8144d12374554
|
||||
settings.autogit.idleThreshold: 28016cc6fccd951a904ee3020cd733b6
|
||||
settings.autogit.inactiveThreshold: d28cb60d1e70e020ae8d1294e32dd474
|
||||
settings.titles.title: 761443f70a5067ecf015c6fb3fae9cef
|
||||
settings.titles.description: b94aeeca78dd376cc19b9aa019e53f6c
|
||||
settings.titles.autoRename: 5383db8e790ebf5f956d9c59cb4c4f67
|
||||
settings.titles.autoRenameDescription: cc28c28d8817736e658082a2261d9a6e
|
||||
settings.vaultContent.title: 78b883ce9acb9c25e611158a518d9185
|
||||
settings.vaultContent.description: a53487bf1c7898a1459dc1510e216f7d
|
||||
settings.vaultContent.hideGitignored: 2c07ee6c8bfe746d356f44275d42f90e
|
||||
settings.vaultContent.hideGitignoredDescription: e8b1ddfa416610f9d6eb299fa123a1d6
|
||||
settings.aiAgents.title: 27f08387b26e1ceeb1b60bd9c97e7a47
|
||||
settings.aiAgents.description: a13222e67eb121676ff6dfc7b085f02d
|
||||
settings.aiAgents.default: 6af573559fd05d8c35bc57b41cc78e24
|
||||
settings.aiAgents.installed: 73329564760013a7824ff9d5d1af91ff
|
||||
settings.aiAgents.missing: ea21841da70e6405af19fabc4ff8bdd9
|
||||
settings.aiAgents.ready: 909a648c713be25fcd050725d0a41e37
|
||||
settings.aiAgents.notInstalled: 0ac3f76ef78bfdbab6331731ee56ba22
|
||||
settings.workflow.title: 24f47cdbe9ddba774a7cc53e51d9032e
|
||||
settings.workflow.description: aa9a07539b87cf407c3edc8a22732d45
|
||||
settings.workflow.explicit: 54a5b5c27937d25271c3127d093e8361
|
||||
settings.workflow.explicitDescription: ce523427b5dd0f9895f63d1fbb90ad24
|
||||
settings.workflow.autoAdvance: 9ed337f5bc61603d19a1ef35f3a3a243
|
||||
settings.workflow.autoAdvanceDescription: ae58ad8abf03df7cbdae8c3a725258f7
|
||||
settings.privacy.title: 0dd6c14e00cc9f45a68c1bca88d1317d
|
||||
settings.privacy.description: 33d53059be620b58e38b8e5c5ab26b27
|
||||
settings.privacy.crashReporting: b4efc5b61526566d431e99242f5c21b4
|
||||
settings.privacy.crashReportingDescription: e9d7c7efce44adc08d0460be1aad5c42
|
||||
settings.privacy.analytics: 29d55bb222ea76e1a17396375dfab228
|
||||
settings.privacy.analyticsDescription: a0f8b61d555d6d3fd279de2f47aaca76
|
||||
settings.footerShortcut: 7dc840d6b0ef9f422a663ba41975871e
|
||||
settings.cancel: ea4788705e6873b424c65e91c2846b19
|
||||
settings.save: c9cc8cce247e49bae79f15173ce97354
|
||||
common.cancel: ea4788705e6873b424c65e91c2846b19
|
||||
ai.permission.safe.short: c6eea0560cd6f377e78dff2c85cc9122
|
||||
ai.permission.safe.control: 386cc66605d50e5bf1fb726f97e55b55
|
||||
ai.permission.safe.tooltip: 3bd635e2b2223da302994d77bfd7edfd
|
||||
ai.permission.powerUser.short: d4a47db271fc1ef3cd17f7396326b057
|
||||
ai.permission.powerUser.control: d4a47db271fc1ef3cd17f7396326b057
|
||||
ai.permission.powerUser.tooltip: 2585ecdb79ca02a3ee779bb61552de03
|
||||
ai.permission.modeAria: 3ff6346566a4c44fd5c7395907125d3e
|
||||
ai.permission.changed: b5d06ad47e6053fe5a73f4571a40e00c
|
||||
ai.panel.title: 4412225fe5b326643b8c2e1d9b7b10ae
|
||||
ai.panel.status.checking: 118740af1c4521b917e29791d661db82
|
||||
ai.panel.status.missing: fe07f8eac233c229d81cbe9aa25668a0
|
||||
ai.panel.status.ready: 296a0ac791eab2df130789f55fdc109b
|
||||
ai.panel.copyMcpConfig: 6a8c9fe401557c870e3a90bbfab01b25
|
||||
ai.panel.mcpConfig: 53bac93d0314941c8d2c1163026f3ad9
|
||||
ai.panel.newChat: 1b0a886d6e77f628ec96c2d71cdb0a9b
|
||||
ai.panel.close: d00ba22b83762f5a6e66db0be9e916d7
|
||||
ai.panel.linkedCount: 5292fda189a357c9597b1cb0e1b0900b
|
||||
ai.panel.empty.checkingTitle: 0dde2076cb53fe497d05d231296d50bb
|
||||
ai.panel.empty.checkingDescription: 31e41510d851c366860377ba0c956f0b
|
||||
ai.panel.empty.missingTitle: a2b764da52cb43b191bb4ecfd8ea4d26
|
||||
ai.panel.empty.missingDescription: 91825c7d86b1d588d44a33e1d94af950
|
||||
ai.panel.empty.withContextTitle: 66f02985937083e4ef62b98cc9f25aee
|
||||
ai.panel.empty.noContextTitle: e919aeb0a49b58e494e43e5f6f6597c9
|
||||
ai.panel.empty.withContextDescription: dd8faefcc8160a2db800a41d6628facb
|
||||
ai.panel.empty.noContextDescription: 6a6e610835808016d1f2790c3f991a54
|
||||
ai.panel.placeholder.checking: 08fec77719c37d3d6279b2641b37b4d3
|
||||
ai.panel.placeholder.missing: 547c492026d115608067988b7cfbb9a9
|
||||
ai.panel.placeholder.ready: 31f5265c2e0432a7ca10d08e5c6f3114
|
||||
ai.panel.send: 747c6a3564774149c989884e0c581d53
|
||||
mcp.setup.manageTitle: d786c7b9ab9b2406258648931bca0e01
|
||||
mcp.setup.setupTitle: b4b373f690c8a0008885b31e78e93a5c
|
||||
mcp.setup.connectedDescription: a2a3be70c0ac9fa7ef112df5252249fa
|
||||
mcp.setup.setupDescription: 8bf199ffbcf303a99abac078c0c5e094
|
||||
mcp.setup.reconnect: 813ba447b5e64c17ff9b68c693358ca3
|
||||
mcp.setup.connect: fb95777dbc9f7b22014dc360a2957652
|
||||
mcp.setup.disconnect: 42ae25231906c83927831e0ef7c317ac
|
||||
mcp.setup.connecting: 182e7eda4e721ad00737460b88701dee
|
||||
mcp.setup.disconnecting: 11de134aefe51cb4a5c545b5a057a871
|
||||
mcp.setup.nodeRequirement: ac58a35cf21d2204fed548c227f77594
|
||||
mcp.setup.writeEntryDescription: 370aa4403d2ffc37f574503bc3cd00e6
|
||||
mcp.setup.clientPathsDescription: 94565852412051c799e78d2f4525ff53
|
||||
mcp.setup.geminiGuidanceDescription: 182cf5b84b2f56cb1cf41dcdb5e093d8
|
||||
mcp.setup.manual.title: 2188ca52346158cbd103ca7ce7c12dd9
|
||||
mcp.setup.manual.copy: 6a8c9fe401557c870e3a90bbfab01b25
|
||||
mcp.setup.manual.loading: 11ee201c121c8e2015c065952474a3ea
|
||||
mcp.setup.manual.unavailable: 8b4330b839c41bad378c86c91ee0b5cd
|
||||
mcp.toast.connected: 7716c0f9dad41a7fdbef592074459239
|
||||
mcp.toast.refreshed: 227d8721ebec3077792b1223c1f30e54
|
||||
mcp.toast.disconnected: 9b5254079561087d679e35aa10c80638
|
||||
mcp.toast.alreadyDisconnected: 645bec2e33bd9de2b334b704d9fa84cf
|
||||
mcp.toast.configCopied: 458b50444b9a1cb069b00475c0951b8a
|
||||
mcp.toast.configCopyFailed: b451cfb95f87458d142d026a8aca74cf
|
||||
mcp.toast.setupFailed: 51f290962c08d8ba76bfc6c19552eaed
|
||||
mcp.toast.disconnectFailed: ddc3387ab6e996ce15bc4950a3548e1e
|
||||
mcp.error.clipboardUnavailable: 3b9ed3fe7dba627edb74e1cdaa02b2ee
|
||||
save.toast.saved: 248336101b461380a4b2391a7625493d
|
||||
save.toast.nothingToSave: 86ae8cef7371e639f3c007449a2f7d1f
|
||||
save.toast.missingActiveVault: 49cf5064a7dce757f895950d8beeed68
|
||||
save.error.failed: b55b9fefe93fabae3c277e4e7956a534
|
||||
save.error.autoFailed: 09b2b131f07baddb5ba8f5a3ee6234c5
|
||||
save.error.invalidPath: c96fb669715e86508f4c5e73da9f6995
|
||||
savedViews.reordered: 531081a566b826eed2e714ce70a0de08
|
||||
locale.en: 78463a384a5aa4fad5fa73e2f506ecfc
|
||||
sidebar.nav.inbox: 3882d32c66e7e768145ecd8f104b0c08
|
||||
sidebar.nav.allNotes: cd76cf851b08a9d2feafaf255bc1f4d5
|
||||
sidebar.nav.archive: e727b00944f81e1d0a95c12886ac4641
|
||||
sidebar.group.favorites: 953bf4d5e8a1807c15dec4e255384b1d
|
||||
sidebar.group.views: 8f56a72923a6ca8ff53462533d89e567
|
||||
sidebar.group.types: 6b20155008db90cf7589c07baa7334e9
|
||||
sidebar.group.folders: d86e2756f698bea5aac3e2276639f042
|
||||
sidebar.action.createView: ba019414ea8c7fcdb4a83a70ec1bb639
|
||||
sidebar.action.editView: acdf34bd08b0692b906d446e590b1eb9
|
||||
sidebar.action.deleteView: c8f4f9cd8ff820f955474e5c9f70ff39
|
||||
sidebar.action.reorderView: ca2f42548fd57160d22fd0809bf8ed6e
|
||||
sidebar.action.moveViewUp: e601b7f0901f12efb71f7821c0fdb79e
|
||||
sidebar.action.moveViewDown: 6f86951085367864e3e4eb13f5c8688e
|
||||
sidebar.action.customizeSections: 050a134cad1e9c6f04abe5d635592703
|
||||
sidebar.action.renameSection: 8936914051df04e7550d0eeb4a31e0e0
|
||||
sidebar.action.customizeIconColor: 9a2f685c74d261ab483ef00dee5314fe
|
||||
sidebar.action.createType: 6cfb8b8aa3fdce38e675819691b48f5c
|
||||
sidebar.action.collapse: 00873bdddd457791609bff1243c40a6b
|
||||
sidebar.action.expand: 8f98e9696f6eed958573012f52710faa
|
||||
sidebar.action.createFolder: 5dc1e97c14fbe72d8b566ce29c39f010
|
||||
sidebar.action.renameFolder: db76034f8218cda07dadb746a5643019
|
||||
sidebar.action.deleteFolder: 2a2f15300f6f7c81d69860ac1796b517
|
||||
sidebar.action.revealFolderMenu: 76f4ed52da9937ae432dcf5a108fabb4
|
||||
sidebar.action.copyFolderPathMenu: 1779ec6018a385912c1a5499a1bbf2b2
|
||||
sidebar.action.renameFolderMenu: deb1d8fa030292e7eda2c244b313aca2
|
||||
sidebar.action.deleteFolderMenu: c78c889ac7396dc148fc859c9221e545
|
||||
sidebar.folder.name: 710417c4e57a6a01500fe4c0c448ce3d
|
||||
sidebar.folder.newName: e0ad5b2db20359a85de80c1231323bea
|
||||
sidebar.folder.expand: b7545cb143816942ff096d890090fb6f
|
||||
sidebar.folder.collapse: e9f8c6da708219313d72d99cc3998388
|
||||
sidebar.section.name: c3642037e309db278bbbfe0590114c02
|
||||
sidebar.section.showInSidebar: e38d073926d6fb3dfc4d4347708e3cf5
|
||||
sidebar.section.toggle: 2992ffbc76e607a2dc0e99781f101637
|
||||
sidebar.disabled.comingSoon: 81151a1669ebd695e837f304a0d8ec79
|
||||
noteList.title.archive: e727b00944f81e1d0a95c12886ac4641
|
||||
noteList.title.changes: c112bb3542e98308d12d5ecb10a67abc
|
||||
noteList.title.inbox: 3882d32c66e7e768145ecd8f104b0c08
|
||||
noteList.title.history: 16d2b386b2034b9488996466aaae0b57
|
||||
noteList.title.view: 4351cfebe4b61d8aa5efa1d020710005
|
||||
noteList.title.notes: f4c6f851b00d5518bf888815de279aba
|
||||
noteList.searchPlaceholder: 4857cd2689a0a40eb4a77cdc745c518e
|
||||
noteList.searchAction: 5012120fc480c67686dd22ee2f9493cd
|
||||
noteList.createNote: f1484bc40bd3fe3430c13fb89e2ce1af
|
||||
noteList.empty.changesError: 4fca500c4b70f61f7d9413c57c34bdc4
|
||||
noteList.empty.noChanges: ad82ac153532f5625760c29c176c5d5f
|
||||
noteList.empty.noArchived: 75fa9e2aaa902f65d433be856a2e8331
|
||||
noteList.empty.noMatching: 22838e3b8d3d174d33d7986ab6bdd693
|
||||
noteList.empty.allOrganized: ea8fbb54d21c03fae1469635e7043b9b
|
||||
noteList.empty.noNotes: 910bd677fdd52f3e6edb4abc1d03ade8
|
||||
noteList.empty.noMatchingItems: 25cd76c4bbd00b682a45687705b41a14
|
||||
noteList.empty.noRelatedItems: 67eecd103b56ef0e56fd892d379b5a5e
|
||||
noteList.sort.modified: 35e0c8c0b180c95d4e122e55ed62cc64
|
||||
noteList.sort.created: 0eceeb45861f9585dd7a97a3e36f85c6
|
||||
noteList.sort.title: b78a3223503896721cca1303f776159b
|
||||
noteList.sort.status: ec53a8c4f07baed5d8825072c89799be
|
||||
noteList.sort.by: 5cd12b67b005056a94f06368a7645b8e
|
||||
noteList.sort.menu: 62bfadcf958eb6ad238e6c71e312a0ce
|
||||
noteList.sort.ascending: cf3fb1ff52ea1eed3347ac5401ee7f0c
|
||||
noteList.sort.descending: e3cf5ac19407b1a62c6fccaff675a53b
|
||||
noteList.filter.open: c3bf447eabe632720a3aa1a7ce401274
|
||||
noteList.filter.archived: 7d69b3cb4cada18ae61811304f8fbcb5
|
||||
noteList.filter.week: d2ce009594dcc60befa6a4e6cbeb71fc
|
||||
noteList.filter.month: 7cbb885aa1164b390a0bc050a64e1812
|
||||
noteList.filter.all: b1c94ca2fbc3e78fc30069c8d0f01680
|
||||
noteList.properties.customizeColumns: be39bb193d872e11bb21388add6eca23
|
||||
noteList.properties.customizeAllColumns: 0d382ece9a9d08bb9ddf10ed2c88d726
|
||||
noteList.properties.customizeInboxColumns: e7cf53b914ce1079801eeee603875660
|
||||
noteList.properties.customizeViewColumns: a5612e5d2aa2e86941d536a85b27c8b4
|
||||
noteList.properties.showInNoteList: f22a57c5fc14ff477000cc66d837c344
|
||||
noteList.properties.searchPlaceholder: 9c7569eb531ff041b2b7cf4de1e5a619
|
||||
noteList.properties.searchLabel: de1bc9695d5e79a75296abf74dc25056
|
||||
noteList.properties.noMatches: 1627ecc53b9e97c94b2b6b1b93a58721
|
||||
noteList.properties.reorder: c0955c7efaa1ce90d449a23cbc52b553
|
||||
noteList.changes.restoreNote: d6e99303c0e0b7b2abce06fe9214788b
|
||||
noteList.changes.discardChanges: 98313f623bb6f464b9a154eca0b99bf3
|
||||
noteList.changes.restoreDescription: 45e13380b0538e6cbff330f99c260c0a
|
||||
noteList.changes.discardDescription: cd953f51a81e10f8c90135e0106a45d6
|
||||
noteList.changes.thisFile: 976b976e66879a470635bf0f660e81fc
|
||||
noteList.changes.restore: 2bd339d85ee3b33e513359ce781b60cc
|
||||
noteList.changes.discard: d94b42030b9785fd754d5c1754961269
|
||||
noteList.changes.cancel: ea4788705e6873b424c65e91c2846b19
|
||||
editor.empty.selectNote: a71ec7c80aefe49c59f6aba033bf453e
|
||||
editor.empty.shortcuts: 1133fdf3ecef6a09dd9e2a185e1bd4ec
|
||||
editor.raw.label: 7a9754c15264b0604aa31dfea32321ea
|
||||
editor.find.findLabel: 4cfa6c981549e990fe2344e4c805405e
|
||||
editor.find.findPlaceholder: 4cfa6c981549e990fe2344e4c805405e
|
||||
editor.find.replaceLabel: 0ebe6df8a3ac338e0512acc741823fdb
|
||||
editor.find.replacePlaceholder: 0ebe6df8a3ac338e0512acc741823fdb
|
||||
editor.find.matchCount: 29d3cd243c6850f5fbac283e79d6d6a7
|
||||
editor.find.noMatches: 3b470c1f6a10f58c8a8cb6b904577786
|
||||
editor.find.invalidRegex: 9704b2ccc6158864810a313702019d68
|
||||
editor.find.regexMustMatchText: 267728c2e7f2889b39847ee9b9b38891
|
||||
editor.find.previousMatch: afb1b7e6bbdfaa97114e1b519a817f13
|
||||
editor.find.nextMatch: 7f4d218b4f566ea7ff69ab8d912ba7b9
|
||||
editor.find.showReplace: 82c8fdb73dfe8baa1759a36c6c0fb282
|
||||
editor.find.hideReplace: a85baf9735f9acf26aa15b70f2ee55fb
|
||||
editor.find.regex: 7486f19f279f95357c84706dd09ce9da
|
||||
editor.find.matchCase: ba945280b8802ee1a7a50140e2fe94e2
|
||||
editor.find.close: b040fbda901d2135cad53e54e0127071
|
||||
editor.find.replace: 0ebe6df8a3ac338e0512acc741823fdb
|
||||
editor.find.replaceAll: b1c94ca2fbc3e78fc30069c8d0f01680
|
||||
editor.toolbar.rawReturn: 5ddcf5e0dc8b933b344bb6ca3d8e131d
|
||||
editor.toolbar.rawOpen: 89ad60735a649b60dacd2301cda0c4ec
|
||||
editor.toolbar.noteWidthNormal: 9bb55413ec536c4a8fa41c4c4464ab44
|
||||
editor.toolbar.noteWidthWide: 1a744f406c9150f76739bd167c3e47ec
|
||||
editor.toolbar.centerLayout: 3fabf7e9b285eeec90704d271f1049b7
|
||||
editor.toolbar.leftLayout: acab6c8612816e012ff9f4220a42e485
|
||||
editor.toolbar.removeFavorite: 7188286e36fc6c1892dd53aaa54237fb
|
||||
editor.toolbar.addFavorite: 910885435dbc44a22b68cb45c79e4a7e
|
||||
editor.toolbar.markUnorganized: 83fb1b23a3609fab493737fe7af0a198
|
||||
editor.toolbar.markOrganized: 9d170c916afae3d7a82456838728ffa5
|
||||
editor.toolbar.noDiff: b2c4189f90648aaeb5cd2e81f9bd8d94
|
||||
editor.toolbar.loadingDiff: 430386d6aae3570fd399a133e41c7372
|
||||
editor.toolbar.showDiff: 08d579de8d3abdcdfce0953a797362c6
|
||||
editor.toolbar.openAi: d2e93006570a66709c8ce0dc27082ef1
|
||||
editor.toolbar.closeAi: cd46973ef73076d612dff9903ce54498
|
||||
editor.toolbar.restoreArchived: 1bff5cf4943af64c05b2e9aeadccbc84
|
||||
editor.toolbar.archive: 63dc964c32e715217b49f8739d49636b
|
||||
editor.toolbar.delete: f48134a07b016a1de05d4ba6d2fbfb41
|
||||
editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4
|
||||
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
|
||||
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
|
||||
editor.filename.rename: c31c2b468229232ad6287e734fe67d96
|
||||
editor.filename.renameToTitle: bff34a6a2dd1eb87c59403946679237f
|
||||
editor.filename.trigger: 4e9739c2f937c828bbf5d1f935fe7fb9
|
||||
editor.banner.archived: 7d69b3cb4cada18ae61811304f8fbcb5
|
||||
editor.banner.unarchive: 9fd02d2eb5ce348a74372eea202a0aec
|
||||
editor.banner.conflict: 384fc5a4b31c662b1e3426a7ef56441e
|
||||
editor.banner.keepMine: 544a2f2e8e09f93919f10e1920d1b69e
|
||||
editor.banner.keepMineTooltip: 9988cb86b051dd728e2d2f852f0283e4
|
||||
editor.banner.keepTheirs: 46131020af44cc6ec4bfa0a8ff39e044
|
||||
editor.banner.keepTheirsTooltip: 9e5b845c4459747d6ab0df6438ccfac4
|
||||
inspector.title.properties: 9fc2d28c05ed9eb1d75ba4465abf15a9
|
||||
inspector.title.propertiesShortcut: 427d1cbdc813cf54c5fac2508d38d407
|
||||
inspector.title.closePropertiesShortcut: 97b953e45042d4143dd19624dc345d29
|
||||
inspector.empty.noNoteSelected: 046d95682b747a30ed8a7b0b1d581629
|
||||
inspector.empty.noProperties: 6864166e0f65d81b1eb474e41fde8822
|
||||
inspector.empty.initializeProperties: edf249d214b68f5afe8634c577b709c4
|
||||
inspector.empty.invalidProperties: 2ee2429c1ffbabcda9f57f91fb6c0b9d
|
||||
inspector.empty.fixInEditor: b86d4934630d68bc5333c0feb25e5f3f
|
||||
inspector.properties.addProperty: 9820ade036bf1bdf80c0b7da24a4ce0a
|
||||
inspector.properties.deleteProperty: f1d0ab48c8596108e949dfc90e13050b
|
||||
inspector.properties.none: 6adf97f83acf6453d4a6a4b1070f3754
|
||||
inspector.properties.missingType: 9179080e7bcc72408f3de7cb944ff400
|
||||
inspector.properties.missingTypeAria: 582c2c46117cff0534d13e7c8a45a3ba
|
||||
inspector.properties.searchTypes: 84c8d94846183a79444bf36667e8d7ea
|
||||
inspector.properties.noMatchingTypes: 9b64c31214171ba3b2d591743990b840
|
||||
inspector.properties.yes: 93cba07454f06a4a960172bbd6e2a435
|
||||
inspector.properties.no: bafd7322c6e97d25b6299b5d6fe8920b
|
||||
inspector.properties.pickDate: e8e91fbba934dc8adfd7e433f5193911
|
||||
inspector.properties.valuePlaceholder: 689202409e48743b914713f96d93947c
|
||||
inspector.properties.propertyName: 7e9982311d69d66d38809e5d85377a08
|
||||
inspector.relationship.add: ec211f7c20af43e742bf2570c3cb84f9
|
||||
inspector.relationship.addRelationship: d2b0cb45dcdbb00ee70db397c36f73ad
|
||||
inspector.relationship.name: 7ed3fccc071d6939eff211b1a6fd9696
|
||||
inspector.relationship.noteTitle: f72ea00f174785710f0369b23c53963c
|
||||
inspector.relationship.createAndOpen: 55bb53fea743c584c4024e7439c55bfe
|
||||
inspector.info.title: 4059b0251f66a18cb56f544728796875
|
||||
inspector.info.modified: 35e0c8c0b180c95d4e122e55ed62cc64
|
||||
inspector.info.created: 0eceeb45861f9585dd7a97a3e36f85c6
|
||||
inspector.info.words: 6f15b8d4b7287d60a8ea3d1c5cbadc84
|
||||
inspector.info.size: 6f6cb72d544962fa333e2e34ce64f719
|
||||
update.available: 992477975168b876be8e77ce2975e390
|
||||
update.releaseNotes: 5dd03e8d039863e563e049be198c3fd3
|
||||
update.updateNow: 99b1054c0f320be9f109c877a5efd0b2
|
||||
update.dismiss: c8a59e7135a20b362f9c768b09454fdb
|
||||
update.downloading: 2c7fdcafb08f831420f661810f826549
|
||||
update.readyRestart: 4d7487b02d955a44d599189ec383e469
|
||||
update.restartNow: 2d9c2140c53daf05855033aaceeaa8fd
|
||||
status.update.check: eb1b4bb3667dd670210c7822badc2f1d
|
||||
status.zoom.reset: dbf36ab275e5fbd256590e65aa1ca9a3
|
||||
status.feedback.contribute: 96ab5025e38aef43fdb73c9830795264
|
||||
status.feedback.label: 9887a4451812854f0f1b6f669a874307
|
||||
status.theme.light: 4abf27a209985375949aaa426c7c7f3d
|
||||
status.theme.dark: 541be2a55a52b518b8e510c476c7cc95
|
||||
status.settings.open: bae08226d065231df6f01b08cd681c9b
|
||||
status.build.unknown: d250349dd489bdfeb0cb0750b8b3ff89
|
||||
status.vault.switch: 8a2ad262643c556ff0cfa99497aaa529
|
||||
status.vault.default: 5b70a213f150f01f0776fa9481ef2ddf
|
||||
status.vault.createEmpty: f37c03f236fbf1516222360a936249e8
|
||||
status.vault.openLocal: a367344e0429a12a5cc9a6cecbbfcde5
|
||||
status.vault.cloneGit: a7d0aef7c6237a36e54fd5a26e9c23fe
|
||||
status.vault.cloneGettingStarted: 9953a11a477b441976a626a9acf5d7df
|
||||
status.vault.notFound: 3119b7fa94c96209bca571b7c7ed0b7e
|
||||
status.vault.reloading: 893da50ef3a9e01d8a99ff5d3ceb55e9
|
||||
status.vault.reloadingTooltip: 4a884bd7d113797ad16f905f70742da8
|
||||
status.vault.remove: 7f3b4f76df23626170940dbc55c70728
|
||||
status.remote.noneConfigured: 18a4edd4e8d862ccb343937f45585fb1
|
||||
status.remote.inSync: a56f571b4df55d88fb06e61e343b3c91
|
||||
status.remote.aheadTitle: 1516f4b92671b83c17441b4cddf25a7a
|
||||
status.remote.behindTitle: 55ee20469bc46b7c61e7515fbfdc0b5d
|
||||
status.remote.ahead: 681557ace3a84e15060ffca1623b75e6
|
||||
status.remote.behind: 1825bb12c857a71861280e490e29debd
|
||||
status.remote.add: f292f8ef85219f0acde6b66eb9c9c0f4
|
||||
status.remote.none: ed3f2ebe0d847584fd62fc9e0db2df33
|
||||
status.remote.noneDescription: f47636010c21c88a6f81cc41a962b26c
|
||||
status.sync.syncing: b21888f3f83c224b3451901da243d00a
|
||||
status.sync.conflict: f1d4ac54357cc0932f385d56814ba7e4
|
||||
status.sync.failed: 85b08b086888f8ee2680ddd3398f3574
|
||||
status.sync.pullRequired: 1ea27a2d3048b078a18665b24e8dcd91
|
||||
status.sync.notSynced: d18d6927f7ffe96f72e18b4da12e306b
|
||||
status.sync.justNow: 7b9275ee2786ef410555041b92b36bb3
|
||||
status.sync.minutesAgo: b3ac76d1e2595531940035a8e2e2d13c
|
||||
status.sync.resolveConflicts: b9f44cc5ee01c964f004f16e3a2833ca
|
||||
status.sync.inProgress: 278d2bf6768809fbf6e97e7bb319a7c0
|
||||
status.sync.pullAndPush: e07c084a9e734a54bd079d5327a924e7
|
||||
status.sync.retry: ca43e18d4f930e8a3e6d403f31d30a39
|
||||
status.sync.now: c03fafa3a653240e6104348e1293bb88
|
||||
status.sync.synced: 5befab0dde764b6dd8b24a34dc30afa7
|
||||
status.sync.conflicts: d35cef9595a935771f5e8158f387227b
|
||||
status.sync.error: 902b0d55fddef6f8d651fe1035b7d4bd
|
||||
status.sync.status: 76590d5f9708692e3735891bdca40903
|
||||
status.sync.pull: 718f59718640c6506b3721fbc8bf3a4d
|
||||
status.conflict.count: 46293d20ed2071fb30762b71fdaa5894
|
||||
status.offline.title: 3bf45a7003646cc4f963810b0b7ac0f5
|
||||
status.offline.label: 8d9da4bc0e49a50e09ac9f7e56789d39
|
||||
status.changes.view: 6d60d24d8f475ddaef94bbbc58514b5a
|
||||
status.changes.label: c112bb3542e98308d12d5ecb10a67abc
|
||||
status.commit.local: 6474fe7e2ed525df3da3eb447943bfcd
|
||||
status.commit.push: a4aaf10ef20cf17a982bb5b6dad198e0
|
||||
status.commit.label: 59d5b10c3a447f036d85cb5ce524c96c
|
||||
status.commit.openOnGitHub: 445ad18680a573c2aab062c3273ae16a
|
||||
status.git.disabledTooltip: 6981fe5b4993e4b3299c774b185ecd70
|
||||
status.git.disabled: 7f822b0ee91b8922b0c41723da867bd1
|
||||
status.history.onlyGit: 7da5397c33ada1b73a81c5a449cbab16
|
||||
status.history.open: 802ec1fd97592612c19ebef7bebbb5c2
|
||||
status.history.label: 16d2b386b2034b9488996466aaae0b57
|
||||
status.mcp.notConnected: 7e8850f4a564088ced4f592996a39309
|
||||
status.mcp.unknown: 051ff29ff900592f75974d0ef554c2eb
|
||||
status.claude.missing: 89021856e6152467f54ed33faed55e2f
|
||||
status.claude.label: 38c66b824d5769e42c3866005296525b
|
||||
status.claude.install: 1f4688bc2a2acc268811c9edcec810a1
|
||||
status.ai.noAgents: 0876a176a6297e79366d0a2935851f81
|
||||
status.ai.noAgentsTooltip: 1bdc02ec2dd7f9701b4371ffd5770318
|
||||
status.ai.selectedMissing: a37f3dbc73725219e90d709fe0d3ad97
|
||||
status.ai.defaultAgent: 79650d57d1678e56c75dbbcba6ea87f7
|
||||
status.ai.restoreDetails: 47a913b58ce40add6521bc93e807476f
|
||||
status.ai.withGuidance: 1eafd34810db6bba54dac7aa549b5182
|
||||
status.ai.active: 059e3a3167c2ab00f4ca872e82a48d98
|
||||
status.ai.unavailable: 0d3e6e3afef545710aa24ed2ea4990af
|
||||
status.ai.install: 349838fb1d851d3e2014b9fe39203275
|
||||
status.ai.installAgent: 78ac3395d977f8b86ca9a02f781f4af8
|
||||
status.ai.vaultGuidance: 7bb4644efe6ae7cec9993c8bd85f1519
|
||||
status.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2
|
||||
status.ai.openOptions: d387b1ab67586c7a2d83db8900a4dd8e
|
||||
pulse.title: 16d2b386b2034b9488996466aaae0b57
|
||||
pulse.today: 1dd1c5fb7f25cd41b291d43a89e3aefd
|
||||
pulse.yesterday: ebfe9ce86e6e9fb953aa7a25b59c1956
|
||||
pulse.commitCount: 17dc5f11868c946f55ff8164d318856d
|
||||
pulse.commitSingular: fffca4d67ea0a788813031b8bbc3b329
|
||||
pulse.commitPlural: a90814b38bbdfe717205f6d24183f6a7
|
||||
pulse.openOnGitHub: ddab0146d46f585f2ab36072c92a0048
|
||||
pulse.expandFiles: 53c567ce9f999dd937fc954c39853e81
|
||||
pulse.collapseFiles: 0ae74096d72faf840cd7d35635aa0cf9
|
||||
pulse.noActivity: cfbd8245c849f5f00b495d61ea14dfdf
|
||||
pulse.emptyDescription: b5ee4968b62908444166182fe8e593c1
|
||||
pulse.retry: 6327b4e59f58137083214a1fec358855
|
||||
pulse.loadingActivity: 26bf6a6bc2fd8148cd8753542a0ddf86
|
||||
pulse.loading: 5f02f05c744a0f875aebb8625ae1486f
|
||||
pulse.loadError: 63c17996fe219e6bd33b27d2b7ce0c96
|
||||
command.switchLanguage: 60f1a6760c7d1da0b1f7f6d0529608ee
|
||||
locale.itIT: 4be8e06d27bca7e1828f2fa9a49ca985
|
||||
locale.frFR: ad225f707802ba118c22987186dd38e8
|
||||
locale.deDE: 86bc3115eb4e9873ac96904a4a68e19e
|
||||
locale.ruRU: deba6920e70615401385fe1fb5a379ec
|
||||
locale.esES: cdbe021be1976335ab583a845edf7ed6
|
||||
locale.ptBR: 26d5352ead4a731f3d11eb2e85d0e297
|
||||
locale.ptPT: 71bfc0d79772120b52c1c0782450ff84
|
||||
locale.es419: edbf64ac382b82a54795fb409beb54be
|
||||
locale.zhCN: 1e81fc32fea0e9f3a978661e4b5e484d
|
||||
locale.jaJP: f32ced6a9ba164c4b3c047fd1d7c882e
|
||||
locale.koKR: d0bdb3cde477d82e766da05ebda50ccb
|
||||
31
lara.yaml
Normal file
31
lara.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
version: "1.0.0"
|
||||
|
||||
project:
|
||||
instruction: >
|
||||
Tolaria is a desktop knowledge-management app. Keep product names, CLI names,
|
||||
markdown wikilinks, frontmatter keys, file paths, and placeholders like
|
||||
{agent}, {zoom}, {language}, {name}, {label}, {file}, and {count} unchanged.
|
||||
|
||||
locales:
|
||||
source: en
|
||||
target:
|
||||
- it-IT
|
||||
- fr-FR
|
||||
- de-DE
|
||||
- ru-RU
|
||||
- es-ES
|
||||
- pt-BR
|
||||
- pt-PT
|
||||
- es-419
|
||||
- zh-CN
|
||||
- zh-TW
|
||||
- ja-JP
|
||||
- ko-KR
|
||||
|
||||
files:
|
||||
json:
|
||||
include:
|
||||
- "src/lib/locales/[locale].json"
|
||||
exclude: []
|
||||
lockedKeys: []
|
||||
ignoredKeys: []
|
||||
@@ -21,35 +21,78 @@ import {
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import WebSocket from 'ws'
|
||||
import { searchNotes, getNote, vaultContext } from './vault.js'
|
||||
import { requireVaultPath } from './vault-path.js'
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
const VAULT_PATH = requireVaultPath()
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
|
||||
|
||||
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
|
||||
// The bridge relays messages to all other clients (the React frontend).
|
||||
let uiSocket = null
|
||||
let reconnectTimer = null
|
||||
let shutdownStarted = false
|
||||
const RECONNECT_INTERVAL_MS = 3000
|
||||
|
||||
function connectUiBridge() {
|
||||
if (shutdownStarted) return
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(WS_UI_URL)
|
||||
uiSocket = ws
|
||||
ws.on('open', () => {
|
||||
uiSocket = ws
|
||||
if (shutdownStarted) {
|
||||
closeUiSocket()
|
||||
return
|
||||
}
|
||||
console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`)
|
||||
})
|
||||
ws.on('close', () => {
|
||||
uiSocket = null
|
||||
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
if (uiSocket === ws) uiSocket = null
|
||||
scheduleUiReconnect()
|
||||
})
|
||||
ws.on('error', () => {
|
||||
// Silent — bridge may not be running yet, will retry
|
||||
})
|
||||
} catch {
|
||||
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
scheduleUiReconnect()
|
||||
}
|
||||
}
|
||||
connectUiBridge()
|
||||
|
||||
function scheduleUiReconnect() {
|
||||
if (shutdownStarted) return
|
||||
|
||||
clearUiReconnectTimer()
|
||||
reconnectTimer = setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
reconnectTimer.unref?.()
|
||||
}
|
||||
|
||||
function clearUiReconnectTimer() {
|
||||
if (!reconnectTimer) return
|
||||
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
function closeUiSocket() {
|
||||
const socket = uiSocket
|
||||
uiSocket = null
|
||||
if (!socket) return
|
||||
|
||||
socket.removeAllListeners()
|
||||
socket.on('error', () => {})
|
||||
if (socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.terminate?.()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
socket.close()
|
||||
} catch {
|
||||
// Ignore close races during process teardown.
|
||||
}
|
||||
socket.terminate?.()
|
||||
}
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
if (!uiSocket || uiSocket.readyState !== WebSocket.OPEN) return
|
||||
@@ -192,10 +235,47 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function shutdown(exitCode = 0) {
|
||||
if (shutdownStarted) return
|
||||
|
||||
shutdownStarted = true
|
||||
clearUiReconnectTimer()
|
||||
closeUiSocket()
|
||||
|
||||
try {
|
||||
await server.close()
|
||||
} catch (error) {
|
||||
console.error(`[mcp] Error while closing server: ${error.message}`)
|
||||
}
|
||||
|
||||
process.exitCode = exitCode
|
||||
setImmediate(() => process.exit(exitCode))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport()
|
||||
server.onclose = () => {
|
||||
void shutdown(0)
|
||||
}
|
||||
process.stdin.once('end', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
process.stdin.once('close', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
process.once('SIGINT', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
process.once('SIGTERM', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
|
||||
connectUiBridge()
|
||||
await server.connect(transport)
|
||||
console.error(`Tolaria MCP server running (vault: ${VAULT_PATH})`)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
void shutdown(1)
|
||||
})
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { describe, it, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
findMarkdownFiles, getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
import { requireVaultPath } from './vault-path.js'
|
||||
import { evaluateBridgeRequest } from './ws-bridge.js'
|
||||
|
||||
let tmpDir
|
||||
const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
|
||||
const MCP_SERVER_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
before(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
|
||||
@@ -191,6 +195,50 @@ describe('evaluateBridgeRequest', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireVaultPath', () => {
|
||||
it('returns the explicit configured vault path', () => {
|
||||
assert.equal(
|
||||
requireVaultPath({ VAULT_PATH: '/tmp/Selected Vault' }),
|
||||
'/tmp/Selected Vault',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects missing vault paths instead of falling back to ~/Laputa', () => {
|
||||
assert.throws(
|
||||
() => requireVaultPath({}),
|
||||
/VAULT_PATH is required/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stdio process lifecycle', () => {
|
||||
it('exits when the MCP client closes stdin', async () => {
|
||||
const child = spawn(process.execPath, ['index.js'], {
|
||||
cwd: MCP_SERVER_DIR,
|
||||
env: { ...process.env, VAULT_PATH: tmpDir, WS_UI_PORT: '65534' },
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
})
|
||||
let stderr = ''
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk
|
||||
})
|
||||
|
||||
await sleep(200)
|
||||
child.stdin.end()
|
||||
|
||||
const exit = await waitForExit(child, 1_500)
|
||||
if (!exit) {
|
||||
child.kill()
|
||||
await waitForExit(child, 1_000)
|
||||
assert.fail(`MCP server stayed alive after stdin closed.\n${stderr}`)
|
||||
}
|
||||
|
||||
assert.equal(exit.signal, null)
|
||||
assert.equal(exit.code, 0, stderr)
|
||||
})
|
||||
})
|
||||
|
||||
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const outsideNote = path.join(outsideDir, 'outside.md')
|
||||
@@ -205,3 +253,28 @@ async function assertRejectsOutsideVault(prefix, resolveNotePath) {
|
||||
await fs.rm(outsideDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function waitForExit(child, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve(null)
|
||||
}, timeoutMs)
|
||||
|
||||
child.once('exit', onExit)
|
||||
|
||||
function onExit(code, signal) {
|
||||
cleanup()
|
||||
resolve({ code, signal })
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timer)
|
||||
child.off('exit', onExit)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
7
mcp-server/vault-path.js
Normal file
7
mcp-server/vault-path.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export function requireVaultPath(env = process.env) {
|
||||
const vaultPath = env.VAULT_PATH?.trim()
|
||||
if (!vaultPath) {
|
||||
throw new Error('VAULT_PATH is required. Open a vault in Tolaria before starting MCP tools.')
|
||||
}
|
||||
return vaultPath
|
||||
}
|
||||
@@ -24,8 +24,8 @@ import { WebSocketServer } from 'ws'
|
||||
import {
|
||||
getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
import { requireVaultPath } from './vault-path.js'
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
const LOOPBACK_HOST = 'localhost'
|
||||
@@ -37,6 +37,12 @@ const TRUSTED_UI_ORIGINS = new Set([
|
||||
|
||||
/** @type {WebSocketServer | null} */
|
||||
let uiBridge = null
|
||||
let vaultPath = null
|
||||
|
||||
function activeVaultPath() {
|
||||
vaultPath ??= requireVaultPath()
|
||||
return vaultPath
|
||||
}
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
if (!uiBridge) return
|
||||
@@ -48,10 +54,10 @@ function broadcastUiAction(action, payload) {
|
||||
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
open_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
read_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
|
||||
vault_context: () => vaultContext(VAULT_PATH),
|
||||
open_note: (args) => getNote(activeVaultPath(), args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
read_note: (args) => getNote(activeVaultPath(), args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
search_notes: (args) => searchNotes(activeVaultPath(), args.query, args.limit),
|
||||
vault_context: () => vaultContext(activeVaultPath()),
|
||||
ui_open_note: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
|
||||
ui_open_tab: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
|
||||
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
@@ -164,6 +170,7 @@ export function startUiBridge(port = WS_UI_PORT) {
|
||||
}
|
||||
|
||||
export function startBridge(port = WS_PORT) {
|
||||
const currentVaultPath = activeVaultPath()
|
||||
const wss = new WebSocketServer({
|
||||
port,
|
||||
host: LOOPBACK_HOST,
|
||||
@@ -171,7 +178,7 @@ export function startBridge(port = WS_PORT) {
|
||||
})
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.error(`[ws-bridge] Client connected (vault: ${VAULT_PATH})`)
|
||||
console.error(`[ws-bridge] Client connected (vault: ${currentVaultPath})`)
|
||||
|
||||
ws.on('message', async (raw) => {
|
||||
try {
|
||||
@@ -192,5 +199,11 @@ export function startBridge(port = WS_PORT) {
|
||||
// Run directly if invoked as main module
|
||||
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
|
||||
if (isMain) {
|
||||
startUiBridge().then(() => startBridge())
|
||||
try {
|
||||
activeVaultPath()
|
||||
startUiBridge().then(() => startBridge())
|
||||
} catch (err) {
|
||||
console.error(`[ws-bridge] ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,15 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"bundle-mcp": "node scripts/bundle-mcp-server.mjs",
|
||||
"lint": "eslint .",
|
||||
"l10n:translate": "lara-cli translate",
|
||||
"l10n:translate:force": "lara-cli translate --force",
|
||||
"l10n:validate": "node scripts/validate-locales.mjs",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "node scripts/run-vitest-coverage.mjs",
|
||||
@@ -57,6 +60,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.564.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"posthog-js": "^1.363.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
@@ -77,6 +81,7 @@
|
||||
"@tauri-apps/cli": "^2.10.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@translated/lara-cli": "^1.3.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
diff --git a/dist/defaultBlocks-DE5GNdJH.js b/dist/defaultBlocks-DE5GNdJH.js
|
||||
index b2761001278486a8b2ac1d10c82420b4994e96d9..fbf4f2f450ed1351d64adeb16263ceacf9ee393c 100644
|
||||
--- a/dist/defaultBlocks-DE5GNdJH.js
|
||||
+++ b/dist/defaultBlocks-DE5GNdJH.js
|
||||
@@ -2761,7 +2761,7 @@ const B = new Ze("SuggestionMenuPlugin"), _n = k(({ editor: e }) => {
|
||||
@@ -2761,7 +2761,7 @@
|
||||
if (a === s) {
|
||||
const c = r.state.doc;
|
||||
for (const l of t) {
|
||||
@@ -11,10 +9,9 @@ index b2761001278486a8b2ac1d10c82420b4994e96d9..fbf4f2f450ed1351d64adeb16263ceac
|
||||
if (l === u)
|
||||
return r.dispatch(r.state.tr.insertText(i)), r.dispatch(
|
||||
r.state.tr.setMeta(B, {
|
||||
diff --git a/src/editor/managers/ExtensionManager/extensions.ts b/src/editor/managers/ExtensionManager/extensions.ts
|
||||
--- a/src/editor/managers/ExtensionManager/extensions.ts
|
||||
+++ b/src/editor/managers/ExtensionManager/extensions.ts
|
||||
@@ -84,7 +84,8 @@ export function getDefaultTiptapExtensions(
|
||||
@@ -84,7 +84,8 @@
|
||||
}).configure({
|
||||
defaultProtocol: DEFAULT_LINK_PROTOCOL,
|
||||
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
|
||||
@@ -24,10 +21,9 @@ diff --git a/src/editor/managers/ExtensionManager/extensions.ts b/src/editor/man
|
||||
}),
|
||||
...(Object.values(editor.schema.styleSpecs).map((styleSpec) => {
|
||||
return styleSpec.implementation.mark.configure({
|
||||
diff --git a/dist/blocknote.js b/dist/blocknote.js
|
||||
--- a/dist/blocknote.js
|
||||
+++ b/dist/blocknote.js
|
||||
@@ -2037,7 +2037,9 @@ const de = () => {
|
||||
@@ -2037,7 +2037,9 @@
|
||||
]
|
||||
})
|
||||
);
|
||||
@@ -38,8 +34,7 @@ diff --git a/dist/blocknote.js b/dist/blocknote.js
|
||||
function mn(o, e) {
|
||||
const t = [
|
||||
I.ClipboardTextSerializer,
|
||||
@@ -2063,8 +2065,9 @@ function mn(o, e) {
|
||||
inclusive: !1
|
||||
@@ -2062,7 +2064,8 @@
|
||||
}).configure({
|
||||
defaultProtocol: Ct,
|
||||
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
|
||||
@@ -49,7 +44,7 @@ diff --git a/dist/blocknote.js b/dist/blocknote.js
|
||||
}),
|
||||
...Object.values(o.schema.styleSpecs).map((n) => n.implementation.mark.configure({
|
||||
editor: o
|
||||
@@ -2112,7 +2115,7 @@ function mn(o, e) {
|
||||
@@ -2112,7 +2115,7 @@
|
||||
),
|
||||
Do(o)
|
||||
];
|
||||
@@ -58,3 +53,40 @@ diff --git a/dist/blocknote.js b/dist/blocknote.js
|
||||
}
|
||||
function kn(o, e) {
|
||||
const t = [
|
||||
--- a/src/extensions/TableHandles/TableHandles.ts
|
||||
+++ b/src/extensions/TableHandles/TableHandles.ts
|
||||
@@ -572,9 +572,14 @@
|
||||
const tableBody = this.tableElement!.querySelector("tbody");
|
||||
|
||||
if (!tableBody) {
|
||||
- throw new Error(
|
||||
- "Table block does not contain a 'tbody' HTML element. This should never happen.",
|
||||
- );
|
||||
+ this.state.show = false;
|
||||
+ this.state.showAddOrRemoveRowsButton = false;
|
||||
+ this.state.showAddOrRemoveColumnsButton = false;
|
||||
+ this.state.rowIndex = undefined;
|
||||
+ this.state.colIndex = undefined;
|
||||
+ this.state.referencePosCell = undefined;
|
||||
+ this.emitUpdate();
|
||||
+ return;
|
||||
}
|
||||
|
||||
if (
|
||||
--- a/dist/TrailingNode-CxM966vN.js
|
||||
+++ b/dist/TrailingNode-CxM966vN.js
|
||||
@@ -1746,10 +1746,10 @@
|
||||
);
|
||||
this.state.rowIndex !== void 0 && this.state.colIndex !== void 0 && (this.state.rowIndex >= e && (this.state.rowIndex = e - 1), this.state.colIndex >= t && (this.state.colIndex = t - 1));
|
||||
const o = this.tableElement.querySelector("tbody");
|
||||
- if (!o)
|
||||
- throw new Error(
|
||||
- "Table block does not contain a 'tbody' HTML element. This should never happen."
|
||||
- );
|
||||
+ if (!o) {
|
||||
+ this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, this.state.rowIndex = void 0, this.state.colIndex = void 0, this.state.referencePosCell = void 0, this.emitUpdate();
|
||||
+ return;
|
||||
+ }
|
||||
if (this.state.rowIndex !== void 0 && this.state.colIndex !== void 0) {
|
||||
const i = o.children[this.state.rowIndex].children[this.state.colIndex];
|
||||
i ? this.state.referencePosCell = i.getBoundingClientRect() : (this.state.rowIndex = void 0, this.state.colIndex = void 0);
|
||||
|
||||
@@ -30,7 +30,7 @@ export default defineConfig({
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
|
||||
webServer: {
|
||||
command: `pnpm dev --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
command: `node scripts/playwright-smoke-server.mjs ${port}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer,
|
||||
timeout: 30_000,
|
||||
|
||||
1783
pnpm-lock.yaml
generated
1783
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path'
|
||||
|
||||
import {
|
||||
buildStableDownloadRedirectPage,
|
||||
resolveStableDmgUrl,
|
||||
resolveStableDownloadTargets,
|
||||
} from '../src/utils/releaseDownloadPage'
|
||||
|
||||
function getArg(flag: string): string {
|
||||
@@ -30,8 +30,8 @@ const releasesJsonPath = resolve(getArg('--releases-json'))
|
||||
const outputFilePath = resolve(getArg('--output-file'))
|
||||
const latestPayload = readLatestReleasePayload(latestJsonPath)
|
||||
const releasesPayload = readLatestReleasePayload(releasesJsonPath)
|
||||
const dmgUrl = resolveStableDmgUrl(latestPayload, releasesPayload)
|
||||
const html = buildStableDownloadRedirectPage(dmgUrl)
|
||||
const downloads = resolveStableDownloadTargets(latestPayload, releasesPayload)
|
||||
const html = buildStableDownloadRedirectPage(downloads)
|
||||
|
||||
mkdirSync(dirname(outputFilePath), { recursive: true })
|
||||
writeFileSync(outputFilePath, html)
|
||||
|
||||
32
scripts/playwright-smoke-server.mjs
Normal file
32
scripts/playwright-smoke-server.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const port = process.argv[2] ?? process.env.PORT ?? '41741'
|
||||
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['dev', '--host', '127.0.0.1', '--port', port, '--strictPort'],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
},
|
||||
)
|
||||
|
||||
function forwardSignal(signal) {
|
||||
if (child.killed) return
|
||||
child.kill(signal)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => forwardSignal('SIGINT'))
|
||||
process.on('SIGTERM', () => forwardSignal('SIGTERM'))
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
111
scripts/validate-locales.mjs
Normal file
111
scripts/validate-locales.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const localesDir = path.join(root, 'src/lib/locales')
|
||||
const sourcePath = path.join(localesDir, 'en.json')
|
||||
|
||||
function readCatalog(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
}
|
||||
|
||||
function isFlatObject(value) {
|
||||
if (!value) return false
|
||||
if (typeof value !== 'object') return false
|
||||
return !Array.isArray(value)
|
||||
}
|
||||
|
||||
function assertFlatStringCatalog(locale, catalog) {
|
||||
if (!isFlatObject(catalog)) {
|
||||
throw new Error(`${locale}: expected a flat object of translation keys`)
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(catalog)) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${locale}: key "${key}" must map to a string`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function missingKeys(sourceKeys, localeKeys) {
|
||||
const localeKeySet = new Set(localeKeys)
|
||||
return sourceKeys.filter((key) => !localeKeySet.has(key))
|
||||
}
|
||||
|
||||
function extraKeys(sourceKeys, localeKeys) {
|
||||
const sourceKeySet = new Set(sourceKeys)
|
||||
return localeKeys.filter((key) => !sourceKeySet.has(key))
|
||||
}
|
||||
|
||||
function placeholders(value) {
|
||||
return Array.from(value.matchAll(/\{(\w+)\}/g), (match) => match[1]).sort()
|
||||
}
|
||||
|
||||
function sameValues(left, right) {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function formatValues(values) {
|
||||
return values.length === 0 ? 'none' : values.join(', ')
|
||||
}
|
||||
|
||||
function placeholderIssues(locale, sourceCatalog, catalog) {
|
||||
const issues = []
|
||||
|
||||
for (const [key, sourceValue] of Object.entries(sourceCatalog)) {
|
||||
if (!(key in catalog)) continue
|
||||
|
||||
const sourcePlaceholders = placeholders(sourceValue)
|
||||
const localePlaceholders = placeholders(catalog[key])
|
||||
if (sameValues(sourcePlaceholders, localePlaceholders)) continue
|
||||
|
||||
issues.push(
|
||||
`${locale}: key "${key}" placeholders differ ` +
|
||||
`(expected ${formatValues(sourcePlaceholders)}, found ${formatValues(localePlaceholders)})`,
|
||||
)
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
const sourceCatalog = readCatalog(sourcePath)
|
||||
assertFlatStringCatalog('en', sourceCatalog)
|
||||
|
||||
const sourceKeys = Object.keys(sourceCatalog).sort()
|
||||
const localeFiles = fs.readdirSync(localesDir).filter((file) => file.endsWith('.json'))
|
||||
const issues = []
|
||||
|
||||
for (const file of localeFiles) {
|
||||
const locale = file.replace(/\.json$/, '')
|
||||
const filePath = path.join(localesDir, file)
|
||||
const catalog = readCatalog(filePath)
|
||||
|
||||
assertFlatStringCatalog(locale, catalog)
|
||||
|
||||
if (locale === 'en') continue
|
||||
|
||||
const keys = Object.keys(catalog).sort()
|
||||
const missing = missingKeys(sourceKeys, keys)
|
||||
const extra = extraKeys(sourceKeys, keys)
|
||||
|
||||
if (missing.length > 0) {
|
||||
issues.push(`${locale}: missing ${missing.length} key(s)`)
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
issues.push(`${locale}: extra ${extra.length} key(s)`)
|
||||
}
|
||||
|
||||
issues.push(...placeholderIssues(locale, sourceCatalog, catalog))
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
console.error('Locale validation failed:')
|
||||
for (const issue of issues) {
|
||||
console.error(`- ${issue}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Validated ${localeFiles.length} locale catalog(s) against ${sourceKeys.length} English keys.`)
|
||||
83
src-tauri/Cargo.lock
generated
83
src-tauri/Cargo.lock
generated
@@ -1239,6 +1239,15 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
@@ -2069,6 +2078,26 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -2207,6 +2236,26 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikiki"
|
||||
version = "0.8.8-speedreader"
|
||||
@@ -2386,6 +2435,18 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
@@ -2489,6 +2550,25 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "6.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"crossbeam-channel",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio 0.8.11",
|
||||
"walkdir",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
@@ -5042,7 +5122,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"mio 1.1.1",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
@@ -5103,6 +5183,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"gray_matter",
|
||||
"log",
|
||||
"notify",
|
||||
"regex",
|
||||
"sentry",
|
||||
"serde",
|
||||
|
||||
@@ -20,6 +20,7 @@ serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
log = "0.4"
|
||||
notify = "6.1"
|
||||
tauri = { version = "2.10.0", features = ["protocol-asset", "devtools"] }
|
||||
tauri-plugin-log = "2"
|
||||
gray_matter = "0.2"
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiAgentId {
|
||||
ClaudeCode,
|
||||
Codex,
|
||||
Opencode,
|
||||
Pi,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiAgentPermissionMode {
|
||||
#[default]
|
||||
Safe,
|
||||
PowerUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -20,6 +28,9 @@ pub struct AiAgentAvailability {
|
||||
pub struct AiAgentsStatus {
|
||||
pub claude_code: AiAgentAvailability,
|
||||
pub codex: AiAgentAvailability,
|
||||
pub opencode: AiAgentAvailability,
|
||||
pub pi: AiAgentAvailability,
|
||||
pub gemini: AiAgentAvailability,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -57,12 +68,22 @@ pub struct AiAgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
pub permission_mode: Option<AiAgentPermissionMode>,
|
||||
}
|
||||
|
||||
impl AiAgentStreamRequest {
|
||||
fn permission_mode(&self) -> AiAgentPermissionMode {
|
||||
self.permission_mode.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
AiAgentsStatus {
|
||||
claude_code: availability_from_claude(),
|
||||
codex: availability_from_codex(),
|
||||
codex: crate::codex_cli::check_cli(),
|
||||
opencode: crate::opencode_cli::check_cli(),
|
||||
pi: crate::pi_cli::check_cli(),
|
||||
gemini: crate::gemini_cli::check_cli(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +91,14 @@ pub fn run_ai_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Res
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let permission_mode = request.permission_mode();
|
||||
match request.agent {
|
||||
AiAgentId::ClaudeCode => {
|
||||
let mapped = crate::claude_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::claude_cli::run_agent_stream(mapped, |event| {
|
||||
if let Some(mapped_event) = map_claude_event(event) {
|
||||
@@ -83,7 +106,42 @@ where
|
||||
}
|
||||
})
|
||||
}
|
||||
AiAgentId::Codex => run_codex_agent_stream(request, emit),
|
||||
AiAgentId::Codex => {
|
||||
let mapped = crate::codex_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::codex_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
AiAgentId::Opencode => {
|
||||
let mapped = crate::opencode_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::opencode_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
AiAgentId::Pi => {
|
||||
let mapped = crate::pi_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::pi_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
AiAgentId::Gemini => {
|
||||
let mapped = crate::gemini_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::gemini_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,261 +153,6 @@ fn availability_from_claude() -> AiAgentAvailability {
|
||||
}
|
||||
}
|
||||
|
||||
fn availability_from_codex() -> AiAgentAvailability {
|
||||
let binary = match find_codex_binary() {
|
||||
Ok(binary) => binary,
|
||||
Err(_) => {
|
||||
return AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
AiAgentAvailability {
|
||||
installed: true,
|
||||
version: version_for_binary(&binary),
|
||||
}
|
||||
}
|
||||
|
||||
fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
Command::new(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_codex_binary_on_path()? {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_existing_binary(codex_binary_candidates()) {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
|
||||
}
|
||||
|
||||
fn find_codex_binary_on_path() -> Result<Option<PathBuf>, String> {
|
||||
let output = Command::new("which")
|
||||
.arg("codex")
|
||||
.output()
|
||||
.map_err(|error| format!("Failed to run `which codex`: {error}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(Some(PathBuf::from(path)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn codex_binary_candidates() -> Vec<PathBuf> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
vec![
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".npm/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
]
|
||||
}
|
||||
|
||||
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
candidates.into_iter().find(|candidate| candidate.exists())
|
||||
}
|
||||
|
||||
fn run_codex_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = find_codex_binary()?;
|
||||
let args = build_codex_args(&request)?;
|
||||
let prompt = build_codex_prompt(&request);
|
||||
|
||||
let mut command = Command::new(binary);
|
||||
command
|
||||
.args(args)
|
||||
.arg(prompt)
|
||||
.current_dir(&request.vault_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn codex: {error}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
|
||||
let mut thread_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(error) => {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format!("Read error: {error}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json = match serde_json::from_str::<serde_json::Value>(&line) {
|
||||
Ok(json) => json,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(id) = json["thread_id"].as_str() {
|
||||
thread_id = id.to_string();
|
||||
}
|
||||
|
||||
dispatch_codex_event(&json, &mut emit);
|
||||
}
|
||||
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Wait failed: {error}"))?;
|
||||
if !status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format_codex_error(stderr_output, status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
fn build_codex_args(request: &AiAgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
|
||||
let mcp_server_path = mcp_server
|
||||
.to_str()
|
||||
.ok_or("Invalid MCP server path")?
|
||||
.to_string();
|
||||
|
||||
Ok(vec![
|
||||
"exec".into(),
|
||||
"--json".into(),
|
||||
"-C".into(),
|
||||
request.vault_path.clone(),
|
||||
"-c".into(),
|
||||
r#"mcp_servers.tolaria.command="node""#.into(),
|
||||
"-c".into(),
|
||||
format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path),
|
||||
"-c".into(),
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#,
|
||||
request.vault_path
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn build_codex_prompt(request: &AiAgentStreamRequest) -> String {
|
||||
match request
|
||||
.system_prompt
|
||||
.as_ref()
|
||||
.map(|prompt| prompt.trim())
|
||||
.filter(|prompt| !prompt.is_empty())
|
||||
{
|
||||
Some(system_prompt) => format!(
|
||||
"System instructions:\n{system_prompt}\n\nUser request:\n{}",
|
||||
request.message
|
||||
),
|
||||
None => request.message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_codex_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
match json["type"].as_str().unwrap_or_default() {
|
||||
"thread.started" => {
|
||||
if let Some(thread_id) = json["thread_id"].as_str() {
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: thread_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
"item.started" => emit_codex_item_event(json, false, emit),
|
||||
"item.completed" => emit_codex_item_event(json, true, emit),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_codex_item_event<F>(json: &serde_json::Value, completed: bool, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let item = &json["item"];
|
||||
let item_type = item["type"].as_str().unwrap_or_default();
|
||||
let item_id = item["id"].as_str().unwrap_or_default();
|
||||
|
||||
match item_type {
|
||||
"command_execution" => {
|
||||
if completed {
|
||||
emit(AiAgentStreamEvent::ToolDone {
|
||||
tool_id: item_id.to_string(),
|
||||
output: item["aggregated_output"]
|
||||
.as_str()
|
||||
.map(|output| output.to_string()),
|
||||
});
|
||||
} else {
|
||||
emit(AiAgentStreamEvent::ToolStart {
|
||||
tool_name: "Bash".into(),
|
||||
tool_id: item_id.to_string(),
|
||||
input: item["command"]
|
||||
.as_str()
|
||||
.map(|command| serde_json::json!({ "command": command }).to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
"agent_message" if completed => {
|
||||
if let Some(text) = item["text"].as_str() {
|
||||
emit(AiAgentStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_codex_error(stderr_output: String, status: String) -> String {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
if is_codex_auth_error(&lower) {
|
||||
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
|
||||
}
|
||||
|
||||
if stderr_output.trim().is_empty() {
|
||||
format!("codex exited with status {status}")
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_codex_auth_error(lower: &str) -> bool {
|
||||
["auth", "login", "sign in"]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAgentStreamEvent> {
|
||||
match event {
|
||||
crate::claude_cli::ClaudeStreamEvent::Init { session_id } => {
|
||||
@@ -377,6 +180,9 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
|
||||
Some(AiAgentStreamEvent::Error { message })
|
||||
}
|
||||
crate::claude_cli::ClaudeStreamEvent::Done => Some(AiAgentStreamEvent::Done),
|
||||
crate::claude_cli::ClaudeStreamEvent::Result { text, .. } if !text.is_empty() => {
|
||||
Some(AiAgentStreamEvent::TextDelta { text })
|
||||
}
|
||||
crate::claude_cli::ClaudeStreamEvent::Result { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -386,92 +192,19 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_status_contains_both_agents() {
|
||||
fn normalize_status_contains_all_agents() {
|
||||
let status = get_ai_agents_status();
|
||||
assert!(matches!(status.claude_code.installed, true | false));
|
||||
assert!(matches!(status.codex.installed, true | false));
|
||||
}
|
||||
let install_flags = [
|
||||
status.claude_code.installed,
|
||||
status.codex.installed,
|
||||
status.opencode.installed,
|
||||
status.pi.installed,
|
||||
status.gemini.installed,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn build_codex_prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_codex_prompt(&AiAgentStreamRequest {
|
||||
agent: AiAgentId::Codex,
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: Some("Be concise".into()),
|
||||
vault_path: "/tmp/vault".into(),
|
||||
});
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_args_uses_safe_default_permissions() {
|
||||
if let Ok(args) = build_codex_args(&AiAgentStreamRequest {
|
||||
agent: AiAgentId::Codex,
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
}) {
|
||||
assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
|
||||
assert!(args.contains(&"--json".to_string()));
|
||||
assert!(args.contains(&"-C".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_command_events_maps_to_bash_events() {
|
||||
let mut events = Vec::new();
|
||||
let started = serde_json::json!({
|
||||
"type": "item.started",
|
||||
"item": {
|
||||
"id": "item_1",
|
||||
"type": "command_execution",
|
||||
"command": "/bin/zsh -lc pwd"
|
||||
}
|
||||
});
|
||||
let completed = serde_json::json!({
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": "item_1",
|
||||
"type": "command_execution",
|
||||
"aggregated_output": "/private/tmp\n"
|
||||
}
|
||||
});
|
||||
|
||||
dispatch_codex_event(&started, &mut |event| events.push(event));
|
||||
dispatch_codex_event(&completed, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. }
|
||||
if tool_name == "Bash" && tool_id == "item_1"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone { tool_id, output }
|
||||
if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_agent_message_maps_to_text_delta() {
|
||||
let mut events = Vec::new();
|
||||
let completed = serde_json::json!({
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": "item_2",
|
||||
"type": "agent_message",
|
||||
"text": "All set"
|
||||
}
|
||||
});
|
||||
|
||||
dispatch_codex_event(&completed, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "All set"
|
||||
));
|
||||
assert!(install_flags
|
||||
.iter()
|
||||
.all(|installed| matches!(installed, true | false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -480,4 +213,17 @@ mod tests {
|
||||
|
||||
assert!(matches!(mapped, Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_claude_result_event_preserves_final_text() {
|
||||
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Result {
|
||||
text: "Final answer from Claude".into(),
|
||||
session_id: "session-1".into(),
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
mapped,
|
||||
Some(AiAgentStreamEvent::TextDelta { text }) if text == "Final answer from Claude"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
pub use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitStatus, Stdio};
|
||||
use std::process::{ExitStatus, Stdio};
|
||||
|
||||
/// Status returned by `check_claude_cli`.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
@@ -50,20 +51,12 @@ pub struct ChatStreamRequest {
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters accepted by `stream_claude_agent`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finding the `claude` binary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_claude_binary_on_path()? {
|
||||
if let Some(binary) = find_claude_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
@@ -78,13 +71,20 @@ pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
|
||||
Err("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code".into())
|
||||
}
|
||||
|
||||
fn find_claude_binary_on_path() -> Result<Option<PathBuf>, String> {
|
||||
let output = Command::new("which")
|
||||
fn find_claude_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command(claude_path_lookup_command())
|
||||
.arg("claude")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run `which claude`: {e}"))?;
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
Ok(path_from_successful_output(&output))
|
||||
fn claude_path_lookup_command() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
}
|
||||
}
|
||||
|
||||
fn find_claude_binary_in_user_shell() -> Option<PathBuf> {
|
||||
@@ -107,7 +107,7 @@ fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
Command::new(shell)
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
@@ -141,16 +141,46 @@ fn claude_binary_candidates() -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
let mut candidates = vec![
|
||||
home.join(".local/bin/claude"),
|
||||
home.join(".local/bin/claude.exe"),
|
||||
home.join(".claude/local/claude"),
|
||||
home.join(".claude/local/claude.exe"),
|
||||
home.join(".local/share/mise/shims/claude"),
|
||||
home.join(".local/share/mise/shims/claude.exe"),
|
||||
home.join(".asdf/shims/claude"),
|
||||
home.join(".asdf/shims/claude.exe"),
|
||||
home.join(".npm-global/bin/claude"),
|
||||
home.join(".npm-global/bin/claude.cmd"),
|
||||
home.join(".npm-global/bin/claude.exe"),
|
||||
home.join(".npm/bin/claude"),
|
||||
home.join(".npm/bin/claude.cmd"),
|
||||
home.join(".npm/bin/claude.exe"),
|
||||
home.join("AppData/Roaming/npm/claude.cmd"),
|
||||
home.join("AppData/Roaming/npm/claude.exe"),
|
||||
home.join("AppData/Local/pnpm/claude.cmd"),
|
||||
home.join("AppData/Local/pnpm/claude.exe"),
|
||||
home.join("scoop/shims/claude.exe"),
|
||||
PathBuf::from("/opt/homebrew/bin/claude"),
|
||||
PathBuf::from("/usr/local/bin/claude"),
|
||||
]
|
||||
];
|
||||
candidates.extend(nvm_node_binary_candidates_for_home(home, "claude"));
|
||||
candidates
|
||||
}
|
||||
|
||||
fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut candidates = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.map(|path| path.join("bin").join(binary_name))
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort();
|
||||
candidates
|
||||
}
|
||||
|
||||
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
@@ -173,16 +203,9 @@ pub fn check_cli() -> ClaudeCliStatus {
|
||||
}
|
||||
};
|
||||
|
||||
let version = Command::new(&bin)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
|
||||
|
||||
ClaudeCliStatus {
|
||||
installed: true,
|
||||
version,
|
||||
version: crate::cli_agent_runtime::version_for_binary(&bin),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +272,11 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
"--include-partial-messages".into(),
|
||||
"--mcp-config".into(),
|
||||
mcp_config,
|
||||
"--dangerously-skip-permissions".into(),
|
||||
"--strict-mcp-config".into(),
|
||||
"--permission-mode".into(),
|
||||
"acceptEdits".into(),
|
||||
"--tools".into(),
|
||||
agent_tools(req.permission_mode).into(),
|
||||
"--no-session-persistence".into(),
|
||||
];
|
||||
|
||||
@@ -263,19 +290,25 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn agent_tools(permission_mode: AiAgentPermissionMode) -> &'static str {
|
||||
match permission_mode {
|
||||
AiAgentPermissionMode::Safe => "Read,Edit,MultiEdit,Write,Glob,Grep,LS",
|
||||
AiAgentPermissionMode::PowerUser => "Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a temporary MCP config JSON string pointing to the vault MCP server.
|
||||
fn build_mcp_config(vault_path: &str) -> Result<String, String> {
|
||||
let server_dir = crate::mcp::mcp_server_dir()?;
|
||||
let index_js = server_dir.join("index.js");
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
let config = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"tolaria": {
|
||||
"command": "node",
|
||||
"args": [index_js.to_string_lossy()],
|
||||
"args": [mcp_server_path],
|
||||
"env": { "VAULT_PATH": vault_path }
|
||||
}
|
||||
}
|
||||
@@ -290,6 +323,8 @@ struct StreamState {
|
||||
tool_inputs: HashMap<String, String>,
|
||||
/// The tool_use id of the block currently being streamed.
|
||||
current_tool_id: Option<String>,
|
||||
/// Tracks whether response text has already been emitted for this run.
|
||||
emitted_text: bool,
|
||||
}
|
||||
|
||||
/// Core subprocess runner shared by chat and agent modes.
|
||||
@@ -303,62 +338,28 @@ fn run_claude_subprocess<F>(
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let mut cmd = Command::new(bin);
|
||||
cmd.args(args)
|
||||
.env_remove("CLAUDECODE") // prevent "nested session" guard
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
|
||||
let mut state = StreamState {
|
||||
session_id: String::new(),
|
||||
tool_inputs: HashMap::new(),
|
||||
current_tool_id: None,
|
||||
emitted_text: false,
|
||||
};
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
emit(ClaudeStreamEvent::Error {
|
||||
message: format!("Read error: {e}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
let cmd = build_claude_command(bin, args, cwd);
|
||||
let run = crate::cli_agent_runtime::run_json_line_process(
|
||||
cmd,
|
||||
"claude",
|
||||
emit,
|
||||
|message| ClaudeStreamEvent::Error { message },
|
||||
|json, emit, session_id| {
|
||||
dispatch_event(json, &mut state, emit);
|
||||
*session_id = state.session_id.clone();
|
||||
},
|
||||
)?;
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue, // skip non-JSON lines
|
||||
};
|
||||
|
||||
dispatch_event(&json, &mut state, emit);
|
||||
}
|
||||
|
||||
// Read stderr for potential error messages.
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|s| std::io::read_to_string(s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
|
||||
|
||||
if !status.success() && state.session_id.is_empty() {
|
||||
if !run.status.success() && state.session_id.is_empty() {
|
||||
emit(ClaudeStreamEvent::Error {
|
||||
message: format_failed_claude_exit(&stderr_output, status),
|
||||
message: format_failed_claude_exit(&run.stderr_output, run.status),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -367,6 +368,23 @@ where
|
||||
Ok(state.session_id)
|
||||
}
|
||||
|
||||
fn build_claude_command(
|
||||
bin: &PathBuf,
|
||||
args: &[String],
|
||||
cwd: Option<&str>,
|
||||
) -> std::process::Command {
|
||||
let mut cmd = crate::hidden_command(bin);
|
||||
cmd.args(args)
|
||||
.env_remove("CLAUDECODE") // prevent "nested session" guard
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
fn format_failed_claude_exit(stderr_output: &str, status: ExitStatus) -> String {
|
||||
if is_claude_auth_error(stderr_output) {
|
||||
return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into();
|
||||
@@ -439,7 +457,15 @@ where
|
||||
if !sid.is_empty() {
|
||||
state.session_id = sid.clone();
|
||||
}
|
||||
let text = json["result"].as_str().unwrap_or("").to_string();
|
||||
let text = if state.emitted_text {
|
||||
String::new()
|
||||
} else {
|
||||
let text = json["result"].as_str().unwrap_or("").to_string();
|
||||
if !text.is_empty() {
|
||||
state.emitted_text = true;
|
||||
}
|
||||
text
|
||||
};
|
||||
emit(ClaudeStreamEvent::Result {
|
||||
text,
|
||||
session_id: sid,
|
||||
@@ -449,19 +475,9 @@ where
|
||||
// --- Complete assistant message (fallback for text when no partials) ---
|
||||
"assistant" => {
|
||||
if let Some(content) = json["message"]["content"].as_array() {
|
||||
let emit_text = !state.emitted_text;
|
||||
for block in content {
|
||||
if block["type"].as_str() == Some("tool_use") {
|
||||
if let (Some(id), Some(name)) =
|
||||
(block["id"].as_str(), block["name"].as_str())
|
||||
{
|
||||
let input = format_tool_input(&block["input"], state, id);
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
dispatch_assistant_content_block(block, emit_text, state, emit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -484,9 +500,7 @@ where
|
||||
match delta["type"].as_str() {
|
||||
Some("text_delta") => {
|
||||
if let Some(text) = delta["text"].as_str() {
|
||||
emit(ClaudeStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
emit_text_delta(text, state, emit);
|
||||
}
|
||||
}
|
||||
Some("thinking_delta") => {
|
||||
@@ -531,6 +545,46 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_assistant_content_block<F>(
|
||||
block: &serde_json::Value,
|
||||
emit_text: bool,
|
||||
state: &mut StreamState,
|
||||
emit: &mut F,
|
||||
) where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
match block["type"].as_str() {
|
||||
Some("text") if emit_text => {
|
||||
if let Some(text) = block["text"].as_str() {
|
||||
emit_text_delta(text, state, emit);
|
||||
}
|
||||
}
|
||||
Some("tool_use") => {
|
||||
if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) {
|
||||
let input = format_tool_input(&block["input"], state, id);
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_text_delta<F>(text: &str, state: &mut StreamState, emit: &mut F)
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
if !text.is_empty() {
|
||||
state.emitted_text = true;
|
||||
}
|
||||
emit(ClaudeStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Build the tool input string, preferring accumulated delta chunks over the
|
||||
/// block's `input` field (which may be empty at stream start).
|
||||
fn format_tool_input(
|
||||
@@ -569,6 +623,85 @@ fn extract_tool_result_text(json: &serde_json::Value) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
use std::ffi::OsString;
|
||||
use std::process::Command;
|
||||
|
||||
macro_rules! chat_request {
|
||||
($message:expr, None, None $(,)?) => {
|
||||
ChatStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: None,
|
||||
session_id: None,
|
||||
}
|
||||
};
|
||||
($message:expr, Some($system_prompt:expr), None $(,)?) => {
|
||||
ChatStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: Some($system_prompt.to_string()),
|
||||
session_id: None,
|
||||
}
|
||||
};
|
||||
($message:expr, None, Some($session_id:expr) $(,)?) => {
|
||||
ChatStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: None,
|
||||
session_id: Some($session_id.to_string()),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! agent_request {
|
||||
($message:expr, None, $permission_mode:expr $(,)?) => {
|
||||
AgentStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: $permission_mode,
|
||||
}
|
||||
};
|
||||
($message:expr, Some($system_prompt:expr), $permission_mode:expr $(,)?) => {
|
||||
AgentStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: Some($system_prompt.to_string()),
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: $permission_mode,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! assert_args_contain {
|
||||
($args:expr, [$($value:expr),+ $(,)?] $(,)?) => {
|
||||
$(
|
||||
assert!($args.contains(&$value.to_string()), "missing {}", $value);
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! assert_args_lack {
|
||||
($args:expr, [$($value:expr),+ $(,)?] $(,)?) => {
|
||||
$(
|
||||
assert!(!$args.contains(&$value.to_string()), "unexpected {}", $value);
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! assert_no_arg_contains {
|
||||
($args:expr, $fragment:expr $(,)?) => {
|
||||
assert!(!$args.iter().any(|arg| arg.contains($fragment)));
|
||||
};
|
||||
}
|
||||
|
||||
fn assert_binary_candidates_include(home: &Path, expected: &[PathBuf]) {
|
||||
let candidates = claude_binary_candidates_for_home(home);
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_cli_returns_status() {
|
||||
@@ -580,6 +713,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_binary_candidates_include_nvm_managed_node_installs() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let claude = home.path().join(".nvm/versions/node/v22.12.0/bin/claude");
|
||||
std::fs::create_dir_all(claude.parent().unwrap()).unwrap();
|
||||
std::fs::write(&claude, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let candidates = claude_binary_candidates_for_home(home.path());
|
||||
|
||||
assert!(candidates.contains(&claude), "missing {}", claude.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_args_use_safe_mode_without_bash_by_default() {
|
||||
let args = build_agent_args(&agent_request!(
|
||||
"Rename the note",
|
||||
None,
|
||||
AiAgentPermissionMode::Safe,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_args_contain!(
|
||||
args,
|
||||
["--strict-mcp-config", "--permission-mode", "acceptEdits"]
|
||||
);
|
||||
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]);
|
||||
assert_no_arg_contains!(args, "Bash");
|
||||
assert_args_lack!(args, ["--dangerously-skip-permissions"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_args_allow_bash_in_power_user_mode_without_dangerous_bypass() {
|
||||
let args = build_agent_args(&agent_request!(
|
||||
"Rename the note",
|
||||
None,
|
||||
AiAgentPermissionMode::PowerUser,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_args_contain!(args, ["--strict-mcp-config"]);
|
||||
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash"]);
|
||||
assert_args_lack!(args, ["--dangerously-skip-permissions"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_mcp_config_is_valid_json() {
|
||||
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
|
||||
@@ -599,6 +776,7 @@ mod tests {
|
||||
session_id: String::new(),
|
||||
tool_inputs: HashMap::new(),
|
||||
current_tool_id: None,
|
||||
emitted_text: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,10 +906,35 @@ mod tests {
|
||||
{ "type": "tool_use", "id": "tu_1", "name": "search_notes", "input": {} }
|
||||
] }
|
||||
}));
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events.len(), 2);
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1")
|
||||
matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Let me search.")
|
||||
);
|
||||
assert!(
|
||||
matches!(&events[1], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_result_after_text_delta_does_not_duplicate_response_text() {
|
||||
let (state, events) = run_dispatch_sequence(vec![
|
||||
serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "Visible reply" } }
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "result",
|
||||
"session_id": "session-1",
|
||||
"result": "Visible reply"
|
||||
}),
|
||||
]);
|
||||
|
||||
assert_eq!(state.session_id, "session-1");
|
||||
assert!(matches!(&events[..],
|
||||
[
|
||||
ClaudeStreamEvent::TextDelta { text },
|
||||
ClaudeStreamEvent::Result { text: result_text, session_id },
|
||||
] if text == "Visible reply" && result_text.is_empty() && session_id == "session-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -882,6 +1085,33 @@ mod tests {
|
||||
|
||||
// --- run_claude_subprocess with mock scripts ---
|
||||
|
||||
#[test]
|
||||
fn build_claude_command_keeps_streaming_process_contract() {
|
||||
let bin = PathBuf::from("claude");
|
||||
let args = vec!["-p".to_string(), "hello".to_string()];
|
||||
let command = build_claude_command(&bin, &args, Some("/tmp/vault"));
|
||||
let actual_args: Vec<OsString> = command.get_args().map(OsStr::to_os_string).collect();
|
||||
let claude_code_env = command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == OsStr::new("CLAUDECODE"))
|
||||
.map(|(_, value)| value.map(OsStr::to_os_string));
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
command.get_program().to_os_string(),
|
||||
actual_args,
|
||||
command.get_current_dir().map(Path::to_path_buf),
|
||||
claude_code_env,
|
||||
),
|
||||
(
|
||||
OsString::from("claude"),
|
||||
vec![OsString::from("-p"), OsString::from("hello")],
|
||||
Some(PathBuf::from("/tmp/vault")),
|
||||
Some(None),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_mock_script(script: &str) -> (Result<String, String>, Vec<ClaudeStreamEvent>) {
|
||||
run_mock_script_with_args(script, &[])
|
||||
@@ -918,6 +1148,99 @@ mod tests {
|
||||
assert!(matches!(&events[3], ClaudeStreamEvent::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open() {
|
||||
use std::io::Read;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.arg("stdin_probe_parent_child")
|
||||
.arg("--ignored")
|
||||
.arg("--nocapture")
|
||||
.env("TOLARIA_STDIN_PROBE_CHILD", "1")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let child_stdin = child.stdin.take().unwrap();
|
||||
let mut stdout = child.stdout.take().unwrap();
|
||||
let mut stderr = child.stderr.take().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
child.kill().unwrap();
|
||||
drop(child_stdin);
|
||||
panic!("stdin probe child timed out");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
|
||||
drop(child_stdin);
|
||||
let mut stdout_text = String::new();
|
||||
let mut stderr_text = String::new();
|
||||
stdout.read_to_string(&mut stdout_text).unwrap();
|
||||
stderr.read_to_string(&mut stderr_text).unwrap();
|
||||
|
||||
assert!(
|
||||
status.success(),
|
||||
"stdin probe child failed with {status}\nstdout:\n{stdout_text}\nstderr:\n{stderr_text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "spawned by run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open"]
|
||||
#[test]
|
||||
fn stdin_probe_parent_child() {
|
||||
if std::env::var_os("TOLARIA_STDIN_PROBE_CHILD").is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let fake_bin = std::env::current_exe().unwrap();
|
||||
let args = vec![
|
||||
"stdin_probe_mock_claude_child".to_string(),
|
||||
"--ignored".to_string(),
|
||||
"--nocapture".to_string(),
|
||||
];
|
||||
std::env::set_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD", "1");
|
||||
let mut events = vec![];
|
||||
let result = run_claude_subprocess(&fake_bin, &args, None, &mut |event| events.push(event));
|
||||
std::env::remove_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD");
|
||||
|
||||
assert_eq!(result.unwrap(), "stdin-ok");
|
||||
assert!(matches!(
|
||||
events.first(),
|
||||
Some(ClaudeStreamEvent::Result { text, session_id })
|
||||
if text == "stdin closed" && session_id == "stdin-ok"
|
||||
));
|
||||
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[ignore = "spawned by stdin_probe_parent_child"]
|
||||
#[test]
|
||||
fn stdin_probe_mock_claude_child() {
|
||||
if std::env::var_os("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD").is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
let mut stdin = String::new();
|
||||
std::io::stdin().read_to_string(&mut stdin).unwrap();
|
||||
assert!(stdin.is_empty(), "stdin was not EOF");
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "result",
|
||||
"result": "stdin closed",
|
||||
"session_id": "stdin-ok"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_skips_blank_and_non_json_lines() {
|
||||
@@ -985,52 +1308,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_basic() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hello".into(),
|
||||
system_prompt: None,
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"-p".to_string()));
|
||||
assert!(args.contains(&"hello".to_string()));
|
||||
assert!(args.contains(&"stream-json".to_string()));
|
||||
assert!(!args.contains(&"--system-prompt".to_string()));
|
||||
assert!(!args.contains(&"--resume".to_string()));
|
||||
let args = build_chat_args(&chat_request!("hello", None, None));
|
||||
|
||||
assert_args_contain!(args, ["-p", "hello", "stream-json"]);
|
||||
assert_args_lack!(args, ["--system-prompt", "--resume"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_with_system_prompt() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hi".into(),
|
||||
system_prompt: Some("You are helpful.".into()),
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"--system-prompt".to_string()));
|
||||
assert!(args.contains(&"You are helpful.".to_string()));
|
||||
let args = build_chat_args(&chat_request!("hi", Some("You are helpful."), None));
|
||||
|
||||
assert_args_contain!(args, ["--system-prompt", "You are helpful."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_empty_system_prompt_is_skipped() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hi".into(),
|
||||
system_prompt: Some(String::new()),
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
let args = build_chat_args(&chat_request!("hi", Some(""), None));
|
||||
|
||||
assert!(!args.contains(&"--system-prompt".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_with_session_id() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "continue".into(),
|
||||
system_prompt: None,
|
||||
session_id: Some("sess-abc".into()),
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"--resume".to_string()));
|
||||
assert!(args.contains(&"sess-abc".to_string()));
|
||||
let args = build_chat_args(&chat_request!("continue", None, Some("sess-abc")));
|
||||
|
||||
assert_args_contain!(args, ["--resume", "sess-abc"]);
|
||||
}
|
||||
|
||||
// --- build_agent_args ---
|
||||
@@ -1038,41 +1340,44 @@ mod tests {
|
||||
#[test]
|
||||
fn build_agent_args_basic() {
|
||||
// build_agent_args calls build_mcp_config which needs mcp_server_dir
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "create note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
}) {
|
||||
assert!(args.contains(&"-p".to_string()));
|
||||
assert!(args.contains(&"create note".to_string()));
|
||||
assert!(args.contains(&"--mcp-config".to_string()));
|
||||
assert!(args.contains(&"--dangerously-skip-permissions".to_string()));
|
||||
assert!(args.contains(&"--no-session-persistence".to_string()));
|
||||
assert!(!args.contains(&"--append-system-prompt".to_string()));
|
||||
// Native tools must NOT be disabled
|
||||
assert!(!args.contains(&"--tools".to_string()));
|
||||
if let Ok(args) = build_agent_args(&agent_request!(
|
||||
"create note",
|
||||
None,
|
||||
AiAgentPermissionMode::Safe,
|
||||
)) {
|
||||
assert_args_contain!(args, ["-p", "create note", "--mcp-config"]);
|
||||
assert_args_contain!(args, ["--strict-mcp-config", "--permission-mode"]);
|
||||
assert_args_contain!(args, ["acceptEdits", "--tools"]);
|
||||
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]);
|
||||
assert_args_contain!(args, ["--no-session-persistence"]);
|
||||
assert_args_lack!(
|
||||
args,
|
||||
[
|
||||
"--dangerously-skip-permissions",
|
||||
"bypassPermissions",
|
||||
"--append-system-prompt",
|
||||
],
|
||||
);
|
||||
assert_no_arg_contains!(args, "Bash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_with_system_prompt() {
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "do it".into(),
|
||||
system_prompt: Some("Act as expert.".into()),
|
||||
vault_path: "/tmp/v".into(),
|
||||
}) {
|
||||
assert!(args.contains(&"--append-system-prompt".to_string()));
|
||||
assert!(args.contains(&"Act as expert.".to_string()));
|
||||
if let Ok(args) = build_agent_args(&agent_request!(
|
||||
"do it",
|
||||
Some("Act as expert."),
|
||||
AiAgentPermissionMode::Safe,
|
||||
)) {
|
||||
assert_args_contain!(args, ["--append-system-prompt", "Act as expert."]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_empty_system_prompt_is_skipped() {
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "x".into(),
|
||||
system_prompt: Some(String::new()),
|
||||
vault_path: "/tmp/v".into(),
|
||||
}) {
|
||||
if let Ok(args) =
|
||||
build_agent_args(&agent_request!("x", Some(""), AiAgentPermissionMode::Safe))
|
||||
{
|
||||
assert!(!args.contains(&"--append-system-prompt".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1082,7 +1387,6 @@ mod tests {
|
||||
#[test]
|
||||
fn claude_binary_candidates_include_supported_local_and_toolchain_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = claude_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/claude"),
|
||||
home.join(".claude/local/claude"),
|
||||
@@ -1090,13 +1394,39 @@ mod tests {
|
||||
home.join(".npm-global/bin/claude"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
assert_binary_candidates_include(&home, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_binary_candidates_include_windows_exe_installs() {
|
||||
let home = PathBuf::from(r"C:\Users\alex");
|
||||
let expected = [
|
||||
home.join(".local/bin/claude.exe"),
|
||||
home.join(".claude/local/claude.exe"),
|
||||
home.join("AppData/Roaming/npm/claude.cmd"),
|
||||
];
|
||||
|
||||
assert_binary_candidates_include(&home, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_path_lookup_command_matches_current_platform() {
|
||||
let expected = if cfg!(windows) { "where" } else { "which" };
|
||||
|
||||
assert_eq!(claude_path_lookup_command(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_existing_binary_finds_windows_exe_candidate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let claude = dir.path().join(".local/bin/claude.exe");
|
||||
std::fs::create_dir_all(claude.parent().unwrap()).unwrap();
|
||||
std::fs::write(&claude, "").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
find_existing_binary(claude_binary_candidates_for_home(dir.path())),
|
||||
Some(claude)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1132,6 +1462,7 @@ mod tests {
|
||||
message: "test".into(),
|
||||
system_prompt: Some("sys".into()),
|
||||
vault_path: "/tmp/nonexistent".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
};
|
||||
let mut events = vec![];
|
||||
let result = run_agent_stream(req, |e| events.push(e));
|
||||
|
||||
186
src-tauri/src/cli_agent_runtime.rs
Normal file
186
src-tauri/src/cli_agent_runtime.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
use crate::ai_agents::{AiAgentPermissionMode, AiAgentStreamEvent};
|
||||
use serde::Deserialize;
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, ExitStatus};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
pub permission_mode: AiAgentPermissionMode,
|
||||
}
|
||||
|
||||
pub(crate) struct JsonLineRun {
|
||||
pub session_id: String,
|
||||
pub stderr_output: String,
|
||||
pub status: ExitStatus,
|
||||
}
|
||||
|
||||
pub(crate) fn build_prompt(message: &str, system_prompt: Option<&str>) -> String {
|
||||
match system_prompt
|
||||
.map(str::trim)
|
||||
.filter(|prompt| !prompt.is_empty())
|
||||
{
|
||||
Some(system_prompt) => {
|
||||
format!("System instructions:\n{system_prompt}\n\nUser request:\n{message}")
|
||||
}
|
||||
None => message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_server_path_string() -> Result<String, String> {
|
||||
Ok(crate::mcp::mcp_server_dir()?
|
||||
.join("index.js")
|
||||
.to_str()
|
||||
.ok_or("Invalid MCP server path")?
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
crate::hidden_command(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_json_line(
|
||||
line: Result<String, std::io::Error>,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
let line = line.map_err(|error| format!("Read error: {error}"))?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(serde_json::from_str::<serde_json::Value>(trimmed).ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_ai_agent_json_line<F>(
|
||||
line: Result<String, std::io::Error>,
|
||||
emit: &mut F,
|
||||
) -> Option<serde_json::Value>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
match parse_json_line(line) {
|
||||
Ok(json) => json,
|
||||
Err(message) => {
|
||||
emit(AiAgentStreamEvent::Error { message });
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_json_line_process<Event, F, H>(
|
||||
mut command: Command,
|
||||
process_name: &'static str,
|
||||
emit: &mut F,
|
||||
error_event: impl Fn(String) -> Event,
|
||||
mut handle_json: H,
|
||||
) -> Result<JsonLineRun, String>
|
||||
where
|
||||
F: FnMut(Event),
|
||||
H: FnMut(&serde_json::Value, &mut F, &mut String),
|
||||
{
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn {process_name}: {error}"))?;
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
let mut session_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
match parse_json_line(line) {
|
||||
Ok(Some(json)) => handle_json(&json, emit, &mut session_id),
|
||||
Ok(None) => {}
|
||||
Err(message) => {
|
||||
emit(error_event(message));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
||||
.unwrap_or_default();
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Wait failed: {error}"))?;
|
||||
|
||||
Ok(JsonLineRun {
|
||||
session_id,
|
||||
stderr_output,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn run_ai_agent_json_stream<F>(
|
||||
command: Command,
|
||||
process_name: &'static str,
|
||||
mut emit: F,
|
||||
session_id: impl Fn(&serde_json::Value) -> Option<&str>,
|
||||
dispatch_event: impl Fn(&serde_json::Value, &mut F),
|
||||
format_error: impl Fn(String, String) -> String,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let run = run_json_line_process(
|
||||
command,
|
||||
process_name,
|
||||
&mut emit,
|
||||
|message| AiAgentStreamEvent::Error { message },
|
||||
|json, emit, active_session_id| {
|
||||
if let Some(id) = session_id(json) {
|
||||
*active_session_id = id.to_string();
|
||||
}
|
||||
dispatch_event(json, emit);
|
||||
},
|
||||
)?;
|
||||
|
||||
if !run.status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format_error(run.stderr_output, run.status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(run.session_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_prompt("Rename the note", Some("Be concise"));
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_prompt_skips_blank_system_prompt() {
|
||||
assert_eq!(
|
||||
build_prompt("Rename the note", Some(" ")),
|
||||
"Rename the note"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_json_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
|
||||
assert!(parse_json_line(Ok(" ".into())).unwrap().is_none());
|
||||
assert!(parse_json_line(Ok("not json".into())).unwrap().is_none());
|
||||
|
||||
let error = parse_json_line(Err(std::io::Error::other("broken pipe"))).unwrap_err();
|
||||
assert!(error.contains("broken pipe"));
|
||||
}
|
||||
}
|
||||
607
src-tauri/src/codex_cli.rs
Normal file
607
src-tauri/src/codex_cli.rs
Normal file
@@ -0,0 +1,607 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
pub use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
let binary = match find_codex_binary() {
|
||||
Ok(binary) => binary,
|
||||
Err(_) => {
|
||||
return AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
AiAgentAvailability {
|
||||
installed: true,
|
||||
version: crate::cli_agent_runtime::version_for_binary(&binary),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = find_codex_binary()?;
|
||||
run_agent_stream_with_binary(&binary, request, emit)
|
||||
}
|
||||
|
||||
fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_codex_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_codex_binary_in_user_shell() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_existing_binary(codex_binary_candidates()) {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
|
||||
}
|
||||
|
||||
fn find_codex_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command("which")
|
||||
.arg("codex")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, "codex"))
|
||||
}
|
||||
|
||||
fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
let mut shells = Vec::new();
|
||||
if let Some(shell) = std::env::var_os("SHELL") {
|
||||
if !shell.is_empty() {
|
||||
shells.push(PathBuf::from(shell));
|
||||
}
|
||||
}
|
||||
shells.push(PathBuf::from("/bin/zsh"));
|
||||
shells.push(PathBuf::from("/bin/bash"));
|
||||
shells
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
||||
if output.status.success() {
|
||||
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
fn codex_binary_candidates() -> Vec<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.map(|home| codex_binary_candidates_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".npm/bin/codex"),
|
||||
home.join(".bun/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
];
|
||||
candidates.extend(nvm_node_binary_candidates_for_home(home, "codex"));
|
||||
candidates
|
||||
}
|
||||
|
||||
fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut candidates = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.map(|path| path.join("bin").join(binary_name))
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort();
|
||||
candidates
|
||||
}
|
||||
|
||||
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
candidates.into_iter().find(|candidate| candidate.exists())
|
||||
}
|
||||
|
||||
fn run_agent_stream_with_binary<F>(
|
||||
binary: &Path,
|
||||
request: AgentStreamRequest,
|
||||
emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let args = build_codex_args(&request)?;
|
||||
let prompt = build_codex_prompt(&request);
|
||||
let command = build_codex_command(binary, args, prompt, &request.vault_path);
|
||||
|
||||
crate::cli_agent_runtime::run_ai_agent_json_stream(
|
||||
command,
|
||||
"codex",
|
||||
emit,
|
||||
codex_session_id,
|
||||
dispatch_codex_event,
|
||||
format_codex_error,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_codex_command(
|
||||
binary: &Path,
|
||||
args: Vec<String>,
|
||||
prompt: String,
|
||||
vault_path: &str,
|
||||
) -> std::process::Command {
|
||||
let mut command = crate::hidden_command(binary);
|
||||
command
|
||||
.args(args)
|
||||
.arg(prompt)
|
||||
.current_dir(vault_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
command
|
||||
}
|
||||
|
||||
fn build_codex_args(request: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
|
||||
Ok(vec![
|
||||
"--sandbox".into(),
|
||||
"workspace-write".into(),
|
||||
"--ask-for-approval".into(),
|
||||
"never".into(),
|
||||
"exec".into(),
|
||||
"--json".into(),
|
||||
"-C".into(),
|
||||
request.vault_path.clone(),
|
||||
"-c".into(),
|
||||
r#"mcp_servers.tolaria.command="node""#.into(),
|
||||
"-c".into(),
|
||||
format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path),
|
||||
"-c".into(),
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#,
|
||||
request.vault_path
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn build_codex_prompt(request: &AgentStreamRequest) -> String {
|
||||
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
|
||||
}
|
||||
|
||||
fn codex_session_id(json: &serde_json::Value) -> Option<&str> {
|
||||
json["thread_id"].as_str()
|
||||
}
|
||||
|
||||
fn dispatch_codex_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
match json["type"].as_str().unwrap_or_default() {
|
||||
"thread.started" => {
|
||||
if let Some(thread_id) = json["thread_id"].as_str() {
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: thread_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
"item.started" => emit_codex_item_event(json, false, emit),
|
||||
"item.completed" => emit_codex_item_event(json, true, emit),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_codex_item_event<F>(json: &serde_json::Value, completed: bool, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let item = &json["item"];
|
||||
let item_type = item["type"].as_str().unwrap_or_default();
|
||||
let item_id = item["id"].as_str().unwrap_or_default();
|
||||
|
||||
match item_type {
|
||||
"command_execution" => {
|
||||
if completed {
|
||||
emit(AiAgentStreamEvent::ToolDone {
|
||||
tool_id: item_id.to_string(),
|
||||
output: item["aggregated_output"]
|
||||
.as_str()
|
||||
.map(|output| output.to_string()),
|
||||
});
|
||||
} else {
|
||||
emit(AiAgentStreamEvent::ToolStart {
|
||||
tool_name: "Bash".into(),
|
||||
tool_id: item_id.to_string(),
|
||||
input: item["command"]
|
||||
.as_str()
|
||||
.map(|command| serde_json::json!({ "command": command }).to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
"agent_message" if completed => {
|
||||
if let Some(text) = item["text"].as_str() {
|
||||
emit(AiAgentStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_codex_error(stderr_output: String, status: String) -> String {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
if is_codex_auth_error(&lower) {
|
||||
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
|
||||
}
|
||||
|
||||
if is_codex_write_permission_error(&lower) {
|
||||
return "Codex could not write to the active vault. Tolaria starts Codex with a workspace-write sandbox, so verify the selected vault folder is writable and retry; writes outside the active vault remain blocked.".into();
|
||||
}
|
||||
|
||||
if stderr_output.trim().is_empty() {
|
||||
format!("codex exited with status {status}")
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_codex_auth_error(lower: &str) -> bool {
|
||||
["auth", "login", "sign in"]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
fn is_codex_write_permission_error(lower: &str) -> bool {
|
||||
[
|
||||
"read-only sandbox",
|
||||
"writing is blocked",
|
||||
"rejected by user approval",
|
||||
"rejected by the environment",
|
||||
]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn executable_script(dir: &Path, name: &str, body: &str) -> PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let script = dir.join(name);
|
||||
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
script
|
||||
}
|
||||
|
||||
fn codex_request(
|
||||
vault_path: &Path,
|
||||
permission_mode: AiAgentPermissionMode,
|
||||
) -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
message: "Summarize".into(),
|
||||
system_prompt: None,
|
||||
vault_path: vault_path.to_string_lossy().into_owned(),
|
||||
permission_mode,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_codex_workspace_write_contract(args: &[String]) {
|
||||
let prefix = [
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"--ask-for-approval",
|
||||
"never",
|
||||
];
|
||||
|
||||
assert_eq!(&args[..prefix.len()], prefix);
|
||||
assert!(!args.iter().any(|arg| arg == "danger-full-access"));
|
||||
assert!(!args
|
||||
.iter()
|
||||
.any(|arg| arg == "--dangerously-bypass-approvals-and-sandbox"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_codex_script(body: &str) -> (String, Vec<AiAgentStreamEvent>) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(dir.path(), "codex", body);
|
||||
let mut events = Vec::new();
|
||||
let thread_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
codex_request(vault.path(), AiAgentPermissionMode::Safe),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
(thread_id, events)
|
||||
}
|
||||
|
||||
fn assert_codex_text_flow(events: &[AiAgentStreamEvent], session: &str, text_delta: &str) {
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Init { session_id } if session_id == session
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == text_delta
|
||||
));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_codex_prompt(&AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: Some("Be concise".into()),
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
});
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_args_uses_safe_default_permissions() {
|
||||
if let Ok(args) = build_codex_args(&AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
}) {
|
||||
assert_eq!(args[4], "exec");
|
||||
assert_codex_workspace_write_contract(&args);
|
||||
assert!(args.contains(&"--json".to_string()));
|
||||
assert!(args.contains(&"-C".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_permission_modes_keep_workspace_write_without_dangerous_bypass() {
|
||||
for permission_mode in [
|
||||
AiAgentPermissionMode::Safe,
|
||||
AiAgentPermissionMode::PowerUser,
|
||||
] {
|
||||
if let Ok(args) = build_codex_args(&AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode,
|
||||
}) {
|
||||
assert_codex_workspace_write_contract(&args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_command_keeps_agent_process_contract() {
|
||||
let binary = PathBuf::from("codex");
|
||||
let args = vec!["exec".to_string(), "--json".to_string()];
|
||||
let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault");
|
||||
let actual_args: Vec<&OsStr> = command.get_args().collect();
|
||||
|
||||
assert_eq!(command.get_program(), OsStr::new("codex"));
|
||||
assert_eq!(
|
||||
actual_args,
|
||||
vec![
|
||||
OsStr::new("exec"),
|
||||
OsStr::new("--json"),
|
||||
OsStr::new("Summarize")
|
||||
]
|
||||
);
|
||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() {
|
||||
let (thread_id, events) = run_codex_script(
|
||||
r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
|
||||
printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Done"}}'
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_eq!(thread_id, "thread_1");
|
||||
assert_codex_text_flow(&events, "thread_1", "Done");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_codex_agent_stream_reports_nonzero_exit_errors() {
|
||||
let (thread_id, events) = run_codex_script(
|
||||
r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
|
||||
printf '%s\n' 'login required' >&2
|
||||
exit 2
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_eq!(thread_id, "thread_1");
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
|
||||
)));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_supported_macos_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = codex_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".bun/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_nvm_managed_node_installs() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let codex = home.path().join(".nvm/versions/node/v22.12.0/bin/codex");
|
||||
std::fs::create_dir_all(codex.parent().unwrap()).unwrap();
|
||||
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let candidates = codex_binary_candidates_for_home(home.path());
|
||||
|
||||
assert!(candidates.contains(&codex), "missing {}", codex.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("missing-codex");
|
||||
let codex = dir.path().join("codex");
|
||||
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let stdout = format!("\n{}\n{}\n", missing.display(), codex.display());
|
||||
|
||||
assert_eq!(first_existing_path(&stdout), Some(codex));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_path_from_shell_finds_codex_from_login_shell() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let codex = dir.path().join("codex");
|
||||
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let shell = dir.path().join("shell");
|
||||
std::fs::write(
|
||||
&shell,
|
||||
format!(
|
||||
"#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n",
|
||||
codex.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_command_events_maps_to_bash_events() {
|
||||
let mut events = Vec::new();
|
||||
let started = serde_json::json!({
|
||||
"type": "item.started",
|
||||
"item": {
|
||||
"id": "item_1",
|
||||
"type": "command_execution",
|
||||
"command": "/bin/zsh -lc pwd"
|
||||
}
|
||||
});
|
||||
let completed = serde_json::json!({
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": "item_1",
|
||||
"type": "command_execution",
|
||||
"aggregated_output": "/private/tmp\n"
|
||||
}
|
||||
});
|
||||
|
||||
dispatch_codex_event(&started, &mut |event| events.push(event));
|
||||
dispatch_codex_event(&completed, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. }
|
||||
if tool_name == "Bash" && tool_id == "item_1"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone { tool_id, output }
|
||||
if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_agent_message_maps_to_text_delta() {
|
||||
let mut events = Vec::new();
|
||||
let completed = serde_json::json!({
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": "item_2",
|
||||
"type": "agent_message",
|
||||
"text": "All set"
|
||||
}
|
||||
});
|
||||
|
||||
dispatch_codex_event(&completed, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "All set"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_codex_error_explains_vault_write_permission_failures() {
|
||||
let message = format_codex_error(
|
||||
"The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
|
||||
"exit status: 1".into(),
|
||||
);
|
||||
|
||||
assert!(message.contains("active vault"));
|
||||
assert!(message.contains("writable"));
|
||||
assert!(message.contains("outside"));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#[cfg(desktop)]
|
||||
use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus};
|
||||
use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus};
|
||||
use crate::claude_cli::{ChatStreamRequest, ClaudeCliStatus};
|
||||
use crate::vault::VaultAiGuidanceStatus;
|
||||
|
||||
use super::expand_tilde;
|
||||
@@ -81,14 +81,6 @@ define_desktop_stream_command!(
|
||||
crate::claude_cli::run_chat_stream
|
||||
);
|
||||
|
||||
#[cfg(desktop)]
|
||||
define_desktop_stream_command!(
|
||||
stream_claude_agent,
|
||||
AgentStreamRequest,
|
||||
"claude-agent-stream",
|
||||
crate::claude_cli::run_agent_stream
|
||||
);
|
||||
|
||||
#[cfg(desktop)]
|
||||
define_desktop_stream_command!(
|
||||
stream_ai_agent,
|
||||
@@ -120,6 +112,18 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
opencode: crate::ai_agents::AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
pi: crate::ai_agents::AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
gemini: crate::ai_agents::AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,15 +136,6 @@ pub async fn stream_claude_chat(
|
||||
Err("Claude CLI is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_claude_agent(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_request: AgentStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
Err("Claude CLI is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_ai_agent(
|
||||
@@ -149,3 +144,31 @@ pub async fn stream_ai_agent(
|
||||
) -> Result<String, String> {
|
||||
Err("CLI AI agents are not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vault::AiGuidanceFileState;
|
||||
|
||||
#[test]
|
||||
fn guidance_commands_report_and_restore_vault_guidance_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_string_lossy().to_string();
|
||||
|
||||
let initial = get_vault_ai_guidance_status(vault_path.clone()).unwrap();
|
||||
assert_eq!(initial.agents_state, AiGuidanceFileState::Missing);
|
||||
assert_eq!(initial.claude_state, AiGuidanceFileState::Missing);
|
||||
assert_eq!(initial.gemini_state, AiGuidanceFileState::Missing);
|
||||
assert!(initial.can_restore);
|
||||
|
||||
let restored = restore_vault_ai_guidance(vault_path.clone()).unwrap();
|
||||
assert_eq!(restored.agents_state, AiGuidanceFileState::Managed);
|
||||
assert_eq!(restored.claude_state, AiGuidanceFileState::Managed);
|
||||
assert_eq!(restored.gemini_state, AiGuidanceFileState::Managed);
|
||||
assert!(!restored.can_restore);
|
||||
|
||||
assert!(dir.path().join("AGENTS.md").exists());
|
||||
assert!(dir.path().join("CLAUDE.md").exists());
|
||||
assert!(dir.path().join("GEMINI.md").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,3 +13,32 @@ pub async fn batch_delete_notes_async(
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_delete_notes_async_validates_and_deletes_inside_vault() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let first = dir.path().join("first.md");
|
||||
let second = dir.path().join("nested/second.md");
|
||||
std::fs::create_dir_all(second.parent().unwrap()).unwrap();
|
||||
std::fs::write(&first, "# First\n").unwrap();
|
||||
std::fs::write(&second, "# Second\n").unwrap();
|
||||
|
||||
let deleted = batch_delete_notes_async(
|
||||
vec![
|
||||
first.to_string_lossy().to_string(),
|
||||
"nested/second.md".to_string(),
|
||||
],
|
||||
Some(dir.path().to_string_lossy().to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(deleted.len(), 2);
|
||||
assert!(!first.exists());
|
||||
assert!(!second.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,30 @@ pub fn delete_vault_folder(vault_path: String, folder_path: String) -> Result<St
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::delete_folder(std::path::Path::new(vault_path.as_ref()), &folder_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folder_commands_route_through_vault_path_boundary() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_string_lossy().to_string();
|
||||
let folder = dir.path().join("Inbox");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
std::fs::write(folder.join("note.md"), "# Note\n").unwrap();
|
||||
|
||||
let renamed = rename_vault_folder(
|
||||
vault_path.clone(),
|
||||
"Inbox".to_string(),
|
||||
"Organized".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(renamed.new_path.ends_with("Organized"));
|
||||
assert!(dir.path().join("Organized/note.md").exists());
|
||||
|
||||
let deleted = delete_vault_folder(vault_path, "Organized".to_string()).unwrap();
|
||||
assert_eq!(deleted, "Organized");
|
||||
assert!(!dir.path().join("Organized").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,22 @@ use crate::git::{
|
||||
|
||||
use super::expand_tilde;
|
||||
|
||||
type VaultPathArg = String;
|
||||
type NotePathArg = String;
|
||||
type CommitHashArg = String;
|
||||
type CommitMessageArg = String;
|
||||
type ConflictStrategyArg = String;
|
||||
type RemoteUrlArg = String;
|
||||
type LocalPathArg = String;
|
||||
|
||||
// ── Git commands (desktop) ──────────────────────────────────────────────────
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
|
||||
pub fn get_file_history(
|
||||
vault_path: VaultPathArg,
|
||||
path: NotePathArg,
|
||||
) -> Result<Vec<GitCommit>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
crate::git::get_file_history(&vault_path, &path)
|
||||
@@ -17,14 +28,14 @@ pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommi
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
|
||||
pub fn get_modified_files(vault_path: VaultPathArg) -> Result<Vec<ModifiedFile>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::get_modified_files(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
|
||||
pub fn get_file_diff(vault_path: VaultPathArg, path: NotePathArg) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
crate::git::get_file_diff(&vault_path, &path)
|
||||
@@ -33,9 +44,9 @@ pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String>
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff_at_commit(
|
||||
vault_path: String,
|
||||
path: String,
|
||||
commit_hash: String,
|
||||
vault_path: VaultPathArg,
|
||||
path: NotePathArg,
|
||||
commit_hash: CommitHashArg,
|
||||
) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
@@ -45,7 +56,7 @@ pub fn get_file_diff_at_commit(
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_vault_pulse(
|
||||
vault_path: String,
|
||||
vault_path: VaultPathArg,
|
||||
limit: Option<usize>,
|
||||
skip: Option<usize>,
|
||||
) -> Result<Vec<PulseCommit>, String> {
|
||||
@@ -57,21 +68,21 @@ pub fn get_vault_pulse(
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn git_commit(vault_path: String, message: String) -> Result<String, String> {
|
||||
pub fn git_commit(vault_path: VaultPathArg, message: CommitMessageArg) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::git_commit(&vault_path, &message)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
|
||||
pub fn get_last_commit_info(vault_path: VaultPathArg) -> Result<Option<LastCommitInfo>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::get_last_commit_info(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
pub async fn git_pull(vault_path: VaultPathArg) -> Result<GitPullResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || crate::git::git_pull(&vault_path))
|
||||
.await
|
||||
@@ -80,14 +91,14 @@ pub async fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
|
||||
pub fn get_conflict_files(vault_path: VaultPathArg) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::get_conflict_files(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_mode(vault_path: String) -> String {
|
||||
pub fn get_conflict_mode(vault_path: VaultPathArg) -> String {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::get_conflict_mode(&vault_path)
|
||||
}
|
||||
@@ -95,9 +106,9 @@ pub fn get_conflict_mode(vault_path: String) -> String {
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn git_resolve_conflict(
|
||||
vault_path: String,
|
||||
file: String,
|
||||
strategy: String,
|
||||
vault_path: VaultPathArg,
|
||||
file: NotePathArg,
|
||||
strategy: ConflictStrategyArg,
|
||||
) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::git_resolve_conflict(&vault_path, &file, &strategy)
|
||||
@@ -105,14 +116,14 @@ pub fn git_resolve_conflict(
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
|
||||
pub fn git_commit_conflict_resolution(vault_path: VaultPathArg) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::git_commit_conflict_resolution(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
pub async fn git_push(vault_path: VaultPathArg) -> Result<GitPushResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || crate::git::git_push(&vault_path))
|
||||
.await
|
||||
@@ -121,7 +132,7 @@ pub async fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, String> {
|
||||
pub async fn git_remote_status(vault_path: VaultPathArg) -> Result<GitRemoteStatus, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || crate::git::git_remote_status(&vault_path))
|
||||
.await
|
||||
@@ -130,30 +141,85 @@ pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, St
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn git_discard_file(vault_path: String, relative_path: String) -> Result<(), String> {
|
||||
pub fn git_discard_file(
|
||||
vault_path: VaultPathArg,
|
||||
relative_path: NotePathArg,
|
||||
) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::discard_file_changes(&vault_path, &relative_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn is_git_repo(vault_path: String) -> bool {
|
||||
pub fn is_git_repo(vault_path: VaultPathArg) -> bool {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
std::path::Path::new(vault_path.as_ref())
|
||||
.join(".git")
|
||||
.is_dir()
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn validate_git_init_target(vault_path: &str) -> Result<(), String> {
|
||||
let path = std::path::Path::new(vault_path);
|
||||
if !path.exists() {
|
||||
return Err("Choose an existing vault folder before initializing Git".to_string());
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Err("Choose a folder before initializing Git".to_string());
|
||||
}
|
||||
|
||||
if is_broad_personal_folder(path) && !has_tolaria_vault_marker(path) {
|
||||
return Err(format!(
|
||||
"Choose a dedicated vault folder before initializing Git. '{}' looks like a broad personal folder; create or select a subfolder such as '{}' instead.",
|
||||
path.display(),
|
||||
path.join("Tolaria").display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn is_broad_personal_folder(path: &std::path::Path) -> bool {
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
matches!(
|
||||
name.to_ascii_lowercase().as_str(),
|
||||
"desktop"
|
||||
| "documents"
|
||||
| "downloads"
|
||||
| "movies"
|
||||
| "music"
|
||||
| "pictures"
|
||||
| "public"
|
||||
| "templates"
|
||||
| "videos"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn has_tolaria_vault_marker(path: &std::path::Path) -> bool {
|
||||
["AGENTS.md", "CLAUDE.md", "type.md", "note.md"]
|
||||
.iter()
|
||||
.any(|file| path.join(file).is_file())
|
||||
|| ["attachments", "type", "views"]
|
||||
.iter()
|
||||
.any(|dir| path.join(dir).is_dir())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn init_git_repo(vault_path: String) -> Result<(), String> {
|
||||
pub fn init_git_repo(vault_path: VaultPathArg) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
validate_git_init_target(&vault_path)?;
|
||||
crate::git::init_repo(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(url: String, local_path: String) -> Result<String, String> {
|
||||
pub fn clone_repo(url: RemoteUrlArg, local_path: LocalPathArg) -> Result<String, String> {
|
||||
let local_path = expand_tilde(&local_path);
|
||||
crate::git::clone_repo(&url, &local_path)
|
||||
}
|
||||
@@ -162,28 +228,31 @@ pub fn clone_repo(url: String, local_path: String) -> Result<String, String> {
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_history(_vault_path: String, _path: String) -> Result<Vec<GitCommit>, String> {
|
||||
pub fn get_file_history(
|
||||
_vault_path: VaultPathArg,
|
||||
_path: NotePathArg,
|
||||
) -> Result<Vec<GitCommit>, String> {
|
||||
Err("Git history is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_modified_files(_vault_path: String) -> Result<Vec<ModifiedFile>, String> {
|
||||
pub fn get_modified_files(_vault_path: VaultPathArg) -> Result<Vec<ModifiedFile>, String> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff(_vault_path: String, _path: String) -> Result<String, String> {
|
||||
pub fn get_file_diff(_vault_path: VaultPathArg, _path: NotePathArg) -> Result<String, String> {
|
||||
Err("Git diff is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff_at_commit(
|
||||
_vault_path: String,
|
||||
_path: String,
|
||||
_commit_hash: String,
|
||||
_vault_path: VaultPathArg,
|
||||
_path: NotePathArg,
|
||||
_commit_hash: CommitHashArg,
|
||||
) -> Result<String, String> {
|
||||
Err("Git diff is not available on mobile".into())
|
||||
}
|
||||
@@ -191,7 +260,7 @@ pub fn get_file_diff_at_commit(
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_vault_pulse(
|
||||
_vault_path: String,
|
||||
_vault_path: VaultPathArg,
|
||||
_limit: Option<usize>,
|
||||
_skip: Option<usize>,
|
||||
) -> Result<Vec<PulseCommit>, String> {
|
||||
@@ -200,59 +269,59 @@ pub fn get_vault_pulse(
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn git_commit(_vault_path: String, _message: String) -> Result<String, String> {
|
||||
pub fn git_commit(_vault_path: VaultPathArg, _message: CommitMessageArg) -> Result<String, String> {
|
||||
Err("Git commit is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_last_commit_info(_vault_path: String) -> Result<Option<LastCommitInfo>, String> {
|
||||
pub fn get_last_commit_info(_vault_path: VaultPathArg) -> Result<Option<LastCommitInfo>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn git_pull(_vault_path: String) -> Result<GitPullResult, String> {
|
||||
pub async fn git_pull(_vault_path: VaultPathArg) -> Result<GitPullResult, String> {
|
||||
Err("Git pull is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_files(_vault_path: String) -> Result<Vec<String>, String> {
|
||||
pub fn get_conflict_files(_vault_path: VaultPathArg) -> Result<Vec<String>, String> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_mode(_vault_path: String) -> String {
|
||||
pub fn get_conflict_mode(_vault_path: VaultPathArg) -> String {
|
||||
"none".to_string()
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn git_resolve_conflict(
|
||||
_vault_path: String,
|
||||
_file: String,
|
||||
_strategy: String,
|
||||
_vault_path: VaultPathArg,
|
||||
_file: NotePathArg,
|
||||
_strategy: ConflictStrategyArg,
|
||||
) -> Result<(), String> {
|
||||
Err("Git conflict resolution is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn git_commit_conflict_resolution(_vault_path: String) -> Result<String, String> {
|
||||
pub fn git_commit_conflict_resolution(_vault_path: VaultPathArg) -> Result<String, String> {
|
||||
Err("Git conflict resolution is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn git_push(_vault_path: String) -> Result<GitPushResult, String> {
|
||||
pub async fn git_push(_vault_path: VaultPathArg) -> Result<GitPushResult, String> {
|
||||
Err("Git push is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, String> {
|
||||
pub async fn git_remote_status(_vault_path: VaultPathArg) -> Result<GitRemoteStatus, String> {
|
||||
Ok(GitRemoteStatus {
|
||||
branch: String::new(),
|
||||
has_remote: false,
|
||||
@@ -263,24 +332,139 @@ pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, S
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn git_discard_file(_vault_path: String, _relative_path: String) -> Result<(), String> {
|
||||
pub fn git_discard_file(
|
||||
_vault_path: VaultPathArg,
|
||||
_relative_path: NotePathArg,
|
||||
) -> Result<(), String> {
|
||||
Err("Git discard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn is_git_repo(_vault_path: String) -> bool {
|
||||
pub fn is_git_repo(_vault_path: VaultPathArg) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn init_git_repo(_vault_path: String) -> Result<(), String> {
|
||||
pub fn init_git_repo(_vault_path: VaultPathArg) -> Result<(), String> {
|
||||
Err("Git init is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(_url: String, _local_path: String) -> Result<String, String> {
|
||||
pub fn clone_repo(_url: RemoteUrlArg, _local_path: LocalPathArg) -> Result<String, String> {
|
||||
Err("Git clone is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn vault_path(dir: &TempDir) -> String {
|
||||
dir.path().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn note_path(dir: &TempDir, name: &str) -> String {
|
||||
dir.path().join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn create_initialized_vault() -> (TempDir, String) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(dir.path().join("note.md"), "# Note\n").unwrap();
|
||||
let vault = vault_path(&dir);
|
||||
init_git_repo(vault.clone()).unwrap();
|
||||
(dir, vault)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_git_commands_route_to_git_backend() {
|
||||
let (dir, vault) = create_initialized_vault();
|
||||
let note = note_path(&dir, "note.md");
|
||||
|
||||
assert!(is_git_repo(vault.clone()));
|
||||
|
||||
fs::write(dir.path().join("note.md"), "# Updated\n").unwrap();
|
||||
let modified = get_modified_files(vault.clone()).unwrap();
|
||||
assert!(modified.iter().any(|file| file.relative_path == "note.md"));
|
||||
|
||||
let diff = get_file_diff(vault.clone(), note.clone()).unwrap();
|
||||
assert!(diff.contains("# Updated"));
|
||||
|
||||
git_commit(vault.clone(), "Update note".to_string()).unwrap();
|
||||
let history = get_file_history(vault.clone(), note.clone()).unwrap();
|
||||
assert!(history.iter().any(|commit| commit.message == "Update note"));
|
||||
|
||||
let last_commit = get_last_commit_info(vault.clone()).unwrap().unwrap();
|
||||
assert!(!last_commit.short_hash.is_empty());
|
||||
|
||||
let commit_diff = get_file_diff_at_commit(
|
||||
vault.clone(),
|
||||
note.clone(),
|
||||
history.first().unwrap().hash.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(commit_diff.contains("# Updated"));
|
||||
|
||||
let pulse = get_vault_pulse(vault.clone(), Some(5), Some(0)).unwrap();
|
||||
assert!(!pulse.is_empty());
|
||||
|
||||
fs::write(dir.path().join("note.md"), "# Discard me\n").unwrap();
|
||||
git_discard_file(vault.clone(), "note.md".to_string()).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.path().join("note.md")).unwrap(),
|
||||
"# Updated\n"
|
||||
);
|
||||
|
||||
assert!(get_conflict_files(vault.clone()).unwrap().is_empty());
|
||||
assert_eq!(get_conflict_mode(vault.clone()), "none");
|
||||
assert!(
|
||||
git_resolve_conflict(vault.clone(), "note.md".to_string(), "invalid".to_string(),)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_git_repo_rejects_broad_personal_folders() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let documents = dir.path().join("Documents");
|
||||
fs::create_dir_all(&documents).unwrap();
|
||||
fs::write(documents.join("unrelated.txt"), "not a vault").unwrap();
|
||||
|
||||
let err = init_git_repo(documents.to_string_lossy().into_owned())
|
||||
.expect_err("expected Documents itself to be rejected before git init");
|
||||
|
||||
assert!(err.contains("dedicated vault folder"));
|
||||
assert!(!documents.join(".git").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_git_repo_allows_named_vault_subfolder_under_documents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("Documents").join("Tolaria");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
let vault = vault.to_string_lossy().into_owned();
|
||||
|
||||
init_git_repo(vault.clone()).unwrap();
|
||||
|
||||
assert!(is_git_repo(vault));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn desktop_remote_commands_report_no_remote() {
|
||||
let (_dir, vault) = create_initialized_vault();
|
||||
|
||||
let pull = git_pull(vault.clone()).await.unwrap();
|
||||
assert_eq!(pull.status, "no_remote");
|
||||
|
||||
let push = git_push(vault.clone()).await.unwrap();
|
||||
assert_eq!(push.status, "error");
|
||||
|
||||
let status = git_remote_status(vault.clone()).await.unwrap();
|
||||
assert!(!status.has_remote);
|
||||
assert_eq!((status.ahead, status.behind), (0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
18
src-tauri/src/commands/git_clone.rs
Normal file
18
src-tauri/src/commands/git_clone.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use super::expand_tilde;
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn clone_git_repo(url: String, local_path: String) -> Result<String, String> {
|
||||
let url = url.trim().to_string();
|
||||
let local_path = expand_tilde(&local_path).into_owned();
|
||||
|
||||
tokio::task::spawn_blocking(move || super::git::clone_repo(url, local_path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn clone_git_repo(_url: String, _local_path: String) -> Result<String, String> {
|
||||
Err("Git clone is not available on mobile".into())
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod ai;
|
||||
mod delete;
|
||||
mod folders;
|
||||
mod git;
|
||||
pub mod git_clone;
|
||||
mod git_connect;
|
||||
mod system;
|
||||
mod vault;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
#[cfg(desktop)]
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use crate::menu;
|
||||
use crate::settings::Settings;
|
||||
@@ -13,6 +16,93 @@ use tauri::Window;
|
||||
|
||||
use super::parse_build_label;
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum TitleBarDoubleClickAction {
|
||||
Fill,
|
||||
Minimize,
|
||||
None,
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn parse_title_bar_double_click_action(value: &str) -> Option<TitleBarDoubleClickAction> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"fill" | "zoom" | "maximize" => Some(TitleBarDoubleClickAction::Fill),
|
||||
"minimize" => Some(TitleBarDoubleClickAction::Minimize),
|
||||
"none" | "no action" | "do nothing" => Some(TitleBarDoubleClickAction::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn parse_legacy_title_bar_double_click_action(value: &str) -> Option<TitleBarDoubleClickAction> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" => Some(TitleBarDoubleClickAction::Minimize),
|
||||
"0" | "false" | "no" => Some(TitleBarDoubleClickAction::Fill),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn read_global_defaults_value(key: &str) -> Option<String> {
|
||||
let output = Command::new("defaults")
|
||||
.args(["read", "-g", key])
|
||||
.output()
|
||||
.ok()?;
|
||||
parse_defaults_read_output(output)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn resolve_title_bar_double_click_action(
|
||||
read_value: impl Fn(&str) -> Option<String>,
|
||||
) -> TitleBarDoubleClickAction {
|
||||
read_value("AppleActionOnDoubleClick")
|
||||
.as_deref()
|
||||
.and_then(parse_title_bar_double_click_action)
|
||||
.or_else(|| {
|
||||
read_value("AppleMiniaturizeOnDoubleClick")
|
||||
.as_deref()
|
||||
.and_then(parse_legacy_title_bar_double_click_action)
|
||||
})
|
||||
.unwrap_or(TitleBarDoubleClickAction::Fill)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn parse_defaults_read_output(output: std::process::Output) -> Option<String> {
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let value = String::from_utf8(output.stdout).ok()?;
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn apply_title_bar_double_click_action(
|
||||
action: TitleBarDoubleClickAction,
|
||||
is_maximized: impl FnOnce() -> Result<bool, String>,
|
||||
maximize: impl FnOnce() -> Result<(), String>,
|
||||
unmaximize: impl FnOnce() -> Result<(), String>,
|
||||
minimize: impl FnOnce() -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
match action {
|
||||
TitleBarDoubleClickAction::Fill => {
|
||||
if is_maximized()? {
|
||||
unmaximize()
|
||||
} else {
|
||||
maximize()
|
||||
}
|
||||
}
|
||||
TitleBarDoubleClickAction::Minimize => minimize(),
|
||||
TitleBarDoubleClickAction::None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── MCP commands (desktop) ──────────────────────────────────────────────────
|
||||
|
||||
#[cfg(desktop)]
|
||||
@@ -41,6 +131,31 @@ pub async fn check_mcp_status(vault_path: String) -> Result<crate::mcp::McpStatu
|
||||
.map_err(|e| format!("MCP status check failed: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn get_mcp_config_snippet(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = super::expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || crate::mcp::mcp_config_snippet(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("MCP config task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(
|
||||
app: tauri::AppHandle,
|
||||
vault_path: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let expanded_vault_path = vault_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(|path| super::expand_tilde(path).into_owned());
|
||||
let vault_path = expanded_vault_path.as_deref().map(std::path::Path::new);
|
||||
|
||||
crate::sync_ws_bridge_for_vault(&app, vault_path).map(str::to_string)
|
||||
}
|
||||
|
||||
// ── MCP commands (mobile stubs) ─────────────────────────────────────────────
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -61,6 +176,18 @@ pub async fn check_mcp_status(_vault_path: String) -> Result<crate::mcp::McpStat
|
||||
Ok(crate::mcp::McpStatus::NotInstalled)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn get_mcp_config_snippet(_vault_path: String) -> Result<String, String> {
|
||||
Err("MCP is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(_vault_path: Option<String>) -> Result<String, String> {
|
||||
Err("MCP is not available on mobile".into())
|
||||
}
|
||||
|
||||
// ── Menu commands ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -72,6 +199,7 @@ pub struct MenuStateUpdate {
|
||||
has_restorable_deleted_note: Option<bool>,
|
||||
has_no_remote: Option<bool>,
|
||||
note_list_search_enabled: Option<bool>,
|
||||
editor_find_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
@@ -96,6 +224,9 @@ pub fn update_menu_state(
|
||||
if let Some(v) = state.note_list_search_enabled {
|
||||
menu::set_note_list_search_items_enabled(&app_handle, v);
|
||||
}
|
||||
if let Some(v) = state.editor_find_enabled {
|
||||
menu::set_editor_find_items_enabled(&app_handle, v);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -153,6 +284,20 @@ pub fn update_current_window_min_size(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn perform_current_window_titlebar_double_click(window: Window) -> Result<(), String> {
|
||||
let action = resolve_title_bar_double_click_action(read_global_defaults_value);
|
||||
|
||||
apply_title_bar_double_click_action(
|
||||
action,
|
||||
|| window.is_maximized().map_err(|e| e.to_string()),
|
||||
|| window.maximize().map_err(|e| e.to_string()),
|
||||
|| window.unmaximize().map_err(|e| e.to_string()),
|
||||
|| window.minimize().map_err(|e| e.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn update_current_window_min_size(
|
||||
@@ -164,6 +309,12 @@ pub fn update_current_window_min_size(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn perform_current_window_titlebar_double_click(_window: tauri::Window) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Settings & config commands ──────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
@@ -242,3 +393,175 @@ pub fn load_vault_list() -> Result<VaultList, String> {
|
||||
pub fn save_vault_list(list: VaultList) -> Result<(), String> {
|
||||
vault_list::save_vault_list(&list)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(desktop)]
|
||||
use std::cell::RefCell;
|
||||
#[cfg(desktop)]
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
#[cfg(desktop)]
|
||||
use std::process::{ExitStatus, Output};
|
||||
#[cfg(desktop)]
|
||||
use std::rc::Rc;
|
||||
|
||||
#[test]
|
||||
fn parses_title_bar_action_values() {
|
||||
for (value, expected) in [
|
||||
("Fill", Some(TitleBarDoubleClickAction::Fill)),
|
||||
("zoom", Some(TitleBarDoubleClickAction::Fill)),
|
||||
("Minimize", Some(TitleBarDoubleClickAction::Minimize)),
|
||||
("No Action", Some(TitleBarDoubleClickAction::None)),
|
||||
("tile", None),
|
||||
] {
|
||||
assert_eq!(parse_title_bar_double_click_action(value), expected);
|
||||
}
|
||||
|
||||
for (value, expected) in [
|
||||
("1", Some(TitleBarDoubleClickAction::Minimize)),
|
||||
("false", Some(TitleBarDoubleClickAction::Fill)),
|
||||
("maybe", None),
|
||||
] {
|
||||
assert_eq!(parse_legacy_title_bar_double_click_action(value), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_title_bar_action_preferences() {
|
||||
assert_eq!(
|
||||
resolve_with(&[
|
||||
("AppleActionOnDoubleClick", "No Action"),
|
||||
("AppleMiniaturizeOnDoubleClick", "1"),
|
||||
]),
|
||||
TitleBarDoubleClickAction::None
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_with(&[("AppleMiniaturizeOnDoubleClick", "1")]),
|
||||
TitleBarDoubleClickAction::Minimize
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_with(&[
|
||||
("AppleActionOnDoubleClick", "tile"),
|
||||
("AppleMiniaturizeOnDoubleClick", "1"),
|
||||
]),
|
||||
TitleBarDoubleClickAction::Minimize
|
||||
);
|
||||
assert_eq!(resolve_with(&[]), TitleBarDoubleClickAction::Fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_defaults_output_variants() {
|
||||
for (code, stdout, expected) in [
|
||||
(0, b" Maximize \n".to_vec(), Some("Maximize")),
|
||||
(1, b"Minimize\n".to_vec(), None),
|
||||
(0, b" \n".to_vec(), None),
|
||||
(0, vec![0xff], None),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_defaults_read_output(output(code, stdout)),
|
||||
expected.map(str::to_string)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_title_bar_actions_to_expected_window_calls() {
|
||||
for (action, state, expected_calls) in [
|
||||
(
|
||||
TitleBarDoubleClickAction::Fill,
|
||||
Ok(false),
|
||||
vec!["is_maximized", "maximize"],
|
||||
),
|
||||
(
|
||||
TitleBarDoubleClickAction::Fill,
|
||||
Ok(true),
|
||||
vec!["is_maximized", "unmaximize"],
|
||||
),
|
||||
(
|
||||
TitleBarDoubleClickAction::Minimize,
|
||||
Ok(false),
|
||||
vec!["minimize"],
|
||||
),
|
||||
(TitleBarDoubleClickAction::None, Ok(false), Vec::new()),
|
||||
] {
|
||||
let (result, calls) = run_action(action, state, Ok(()), Ok(()), Ok(()));
|
||||
assert_eq!(result, Ok(()));
|
||||
assert_eq!(calls, expected_calls);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagates_title_bar_action_errors() {
|
||||
for (state, maximize, unmaximize, expected) in [
|
||||
(Err("state"), Ok(()), Ok(()), "state"),
|
||||
(Ok(false), Err("maximize"), Ok(()), "maximize"),
|
||||
(Ok(true), Ok(()), Err("unmaximize"), "unmaximize"),
|
||||
] {
|
||||
let (result, _) = run_action(
|
||||
TitleBarDoubleClickAction::Fill,
|
||||
state,
|
||||
maximize,
|
||||
unmaximize,
|
||||
Ok(()),
|
||||
);
|
||||
assert_eq!(result, Err(expected.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn exit_status(code: i32) -> ExitStatus {
|
||||
ExitStatus::from_raw(code << 8)
|
||||
}
|
||||
|
||||
fn output(code: i32, stdout: Vec<u8>) -> Output {
|
||||
Output {
|
||||
status: exit_status(code),
|
||||
stdout,
|
||||
stderr: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_with(values: &[(&str, &str)]) -> TitleBarDoubleClickAction {
|
||||
resolve_title_bar_double_click_action(|key| {
|
||||
values
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == key)
|
||||
.map(|(_, value)| (*value).to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn run_action(
|
||||
action: TitleBarDoubleClickAction,
|
||||
state: Result<bool, &'static str>,
|
||||
maximize: Result<(), &'static str>,
|
||||
unmaximize: Result<(), &'static str>,
|
||||
minimize: Result<(), &'static str>,
|
||||
) -> (Result<(), String>, Vec<&'static str>) {
|
||||
let calls = Rc::new(RefCell::new(Vec::new()));
|
||||
let state_calls = Rc::clone(&calls);
|
||||
let maximize_calls = Rc::clone(&calls);
|
||||
let unmaximize_calls = Rc::clone(&calls);
|
||||
let minimize_calls = Rc::clone(&calls);
|
||||
let result = apply_title_bar_double_click_action(
|
||||
action,
|
||||
move || {
|
||||
state_calls.borrow_mut().push("is_maximized");
|
||||
state.map_err(str::to_string)
|
||||
},
|
||||
move || {
|
||||
maximize_calls.borrow_mut().push("maximize");
|
||||
maximize.map_err(str::to_string)
|
||||
},
|
||||
move || {
|
||||
unmaximize_calls.borrow_mut().push("unmaximize");
|
||||
unmaximize.map_err(str::to_string)
|
||||
},
|
||||
move || {
|
||||
minimize_calls.borrow_mut().push("minimize");
|
||||
minimize.map_err(str::to_string)
|
||||
},
|
||||
);
|
||||
let call_log = calls.borrow().clone();
|
||||
(result, call_log)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,48 @@ mod tests {
|
||||
Some(vault_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn assert_note_write_rejects_escape<T: std::fmt::Debug>(
|
||||
action: impl FnOnce(std::path::PathBuf, String, Option<std::path::PathBuf>) -> Result<T, String>,
|
||||
) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path();
|
||||
let escape_path = vault_path.join("../outside.md");
|
||||
|
||||
let err = action(
|
||||
escape_path,
|
||||
"# Outside\n".to_string(),
|
||||
vault_path_arg(vault_path),
|
||||
)
|
||||
.expect_err("expected traversal write to be rejected");
|
||||
|
||||
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
|
||||
}
|
||||
|
||||
fn sample_view_definition() -> ViewDefinition {
|
||||
ViewDefinition {
|
||||
name: "Inbox".to_string(),
|
||||
icon: None,
|
||||
color: None,
|
||||
order: None,
|
||||
sort: None,
|
||||
list_properties_display: vec![],
|
||||
filters: crate::vault::FilterGroup::All(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_save_view_cmd_rejects_invalid_filename(filename: &str) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let err = save_view_cmd(
|
||||
dir.path().to_string_lossy().to_string(),
|
||||
filename.to_string(),
|
||||
sample_view_definition(),
|
||||
)
|
||||
.expect_err("expected invalid filename to be rejected");
|
||||
|
||||
assert_eq!(err, INVALID_VIEW_FILENAME_ERROR);
|
||||
}
|
||||
|
||||
fn temp_note(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let note = dir.path().join("note.md");
|
||||
@@ -125,34 +167,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_save_note_content_rejects_traversal_outside_active_vault() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path();
|
||||
let escape_path = vault_path.join("../outside.md");
|
||||
|
||||
let err = save_note_content(
|
||||
escape_path,
|
||||
"# Outside\n".to_string(),
|
||||
vault_path_arg(vault_path),
|
||||
)
|
||||
.expect_err("expected traversal write to be rejected");
|
||||
|
||||
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
|
||||
assert_note_write_rejects_escape(save_note_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_note_content_rejects_traversal_outside_active_vault() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path();
|
||||
let escape_path = vault_path.join("../outside.md");
|
||||
|
||||
let err = create_note_content(
|
||||
escape_path,
|
||||
"# Outside\n".to_string(),
|
||||
vault_path_arg(vault_path),
|
||||
)
|
||||
.expect_err("expected traversal create to be rejected");
|
||||
|
||||
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
|
||||
assert_note_write_rejects_escape(create_note_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -166,25 +186,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_view_cmd_rejects_nested_filename() {
|
||||
fn test_create_vault_folder_rejects_windows_invalid_names() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let definition = ViewDefinition {
|
||||
name: "Inbox".to_string(),
|
||||
icon: None,
|
||||
color: None,
|
||||
sort: None,
|
||||
list_properties_display: vec![],
|
||||
filters: crate::vault::FilterGroup::All(vec![]),
|
||||
};
|
||||
|
||||
let err = save_view_cmd(
|
||||
dir.path().to_string_lossy().to_string(),
|
||||
"../escape.yml".to_string(),
|
||||
definition,
|
||||
)
|
||||
.expect_err("expected nested filename to be rejected");
|
||||
let err = create_vault_folder(dir.path().into(), "con".into())
|
||||
.expect_err("expected Windows-invalid folder name to be rejected");
|
||||
|
||||
assert_eq!(err, INVALID_VIEW_FILENAME_ERROR);
|
||||
assert_eq!(err, "Invalid folder name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_view_cmd_rejects_nested_filename() {
|
||||
assert_save_view_cmd_rejects_invalid_filename("../escape.yml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_view_cmd_rejects_windows_invalid_filename() {
|
||||
assert_save_view_cmd_rejects_invalid_filename("con.yml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::commands::expand_tilde;
|
||||
use crate::vault::filename_rules::validate_view_filename_stem;
|
||||
use crate::vault_list;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
@@ -199,7 +200,11 @@ pub(crate) fn validate_view_filename(filename: &str) -> Result<(), String> {
|
||||
let path = Path::new(filename);
|
||||
let mut components = path.components();
|
||||
match (components.next(), components.next()) {
|
||||
(Some(Component::Normal(_)), None) => Ok(()),
|
||||
(Some(Component::Normal(value)), None) => {
|
||||
let stem = value.to_string_lossy();
|
||||
let stem = stem.strip_suffix(".yml").unwrap_or(&stem);
|
||||
validate_view_filename_stem(stem)
|
||||
}
|
||||
_ => Err(INVALID_VIEW_FILENAME_ERROR.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::commands::expand_tilde;
|
||||
use crate::vault::filename_rules::validate_folder_name;
|
||||
use crate::vault::{self, FolderNode, VaultEntry};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -22,6 +23,14 @@ fn with_note_path<T>(
|
||||
)
|
||||
}
|
||||
|
||||
fn with_external_file_path<T>(
|
||||
path: &Path,
|
||||
vault_path: Option<&Path>,
|
||||
action: impl FnOnce(&Path) -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
with_note_path(path, vault_path, ValidatedPathMode::Existing, action)
|
||||
}
|
||||
|
||||
fn with_expanded_vault_root<T>(
|
||||
path: &Path,
|
||||
action: impl FnOnce(&Path) -> Result<T, String>,
|
||||
@@ -39,6 +48,61 @@ fn with_requested_root_path<T>(
|
||||
with_requested_root(raw_vault_path.as_ref(), action)
|
||||
}
|
||||
|
||||
fn sync_image_asset_scope(
|
||||
app_handle: &tauri::AppHandle,
|
||||
requested_root: &str,
|
||||
) -> Result<(), String> {
|
||||
#[cfg(desktop)]
|
||||
crate::sync_vault_asset_scope(app_handle, Path::new(requested_root))?;
|
||||
#[cfg(not(desktop))]
|
||||
let _ = requested_root;
|
||||
#[cfg(not(desktop))]
|
||||
let _ = app_handle;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn with_image_asset_scope(
|
||||
app_handle: &tauri::AppHandle,
|
||||
vault_path: &Path,
|
||||
action: impl FnOnce(&str) -> Result<String, String>,
|
||||
) -> Result<String, String> {
|
||||
with_requested_root_path(vault_path, |requested_root| {
|
||||
let saved_path = action(requested_root)?;
|
||||
sync_image_asset_scope(app_handle, requested_root)?;
|
||||
Ok(saved_path)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sync_vault_asset_scope_for_window(
|
||||
app_handle: tauri::AppHandle,
|
||||
vault_path: PathBuf,
|
||||
) -> Result<(), String> {
|
||||
with_requested_root_path(vault_path.as_path(), |requested_root| {
|
||||
sync_image_asset_scope(&app_handle, requested_root)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_vault_file_external(
|
||||
app_handle: tauri::AppHandle,
|
||||
path: PathBuf,
|
||||
vault_path: Option<PathBuf>,
|
||||
) -> Result<(), String> {
|
||||
with_external_file_path(path.as_path(), vault_path.as_deref(), |validated_path| {
|
||||
open_path_with_default_app(&app_handle, validated_path)
|
||||
})
|
||||
}
|
||||
|
||||
fn open_path_with_default_app(app_handle: &tauri::AppHandle, path: &Path) -> Result<(), String> {
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
app_handle
|
||||
.opener()
|
||||
.open_path(path.to_string_lossy().into_owned(), None::<String>)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn with_writable_note_path<T>(
|
||||
path: PathBuf,
|
||||
vault_path: Option<PathBuf>,
|
||||
@@ -65,6 +129,20 @@ pub fn get_note_content(path: PathBuf, vault_path: Option<PathBuf>) -> Result<St
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn validate_note_content(
|
||||
path: PathBuf,
|
||||
content: String,
|
||||
vault_path: Option<PathBuf>,
|
||||
) -> Result<bool, String> {
|
||||
with_note_path(
|
||||
path.as_path(),
|
||||
vault_path.as_deref(),
|
||||
ValidatedPathMode::Existing,
|
||||
|validated_path| vault::note_content_matches(validated_path, &content),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_note_content(
|
||||
path: PathBuf,
|
||||
@@ -114,6 +192,7 @@ pub fn create_vault_folder(vault_path: PathBuf, folder_name: PathBuf) -> Result<
|
||||
with_boundary(Some(raw_vault_path.as_ref()), |boundary| {
|
||||
let folder_name = folder_name.to_string_lossy();
|
||||
let folder_path = boundary.child_path(folder_name.as_ref())?;
|
||||
validate_folder_name(folder_name.as_ref())?;
|
||||
ensure_missing_folder(&folder_path, folder_name.as_ref())?;
|
||||
std::fs::create_dir_all(&folder_path)
|
||||
.map_err(|e| format!("Failed to create folder: {}", e))?;
|
||||
@@ -128,6 +207,24 @@ fn ensure_missing_folder(folder_path: &Path, folder_name: &str) -> Result<(), St
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scan_visible_vault_entries(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
let entries = vault::scan_vault_cached(vault_path)?;
|
||||
Ok(vault::filter_gitignored_entries(
|
||||
vault_path,
|
||||
entries,
|
||||
crate::settings::hide_gitignored_files_enabled(),
|
||||
))
|
||||
}
|
||||
|
||||
fn scan_visible_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String> {
|
||||
let folders = vault::scan_vault_folders(vault_path)?;
|
||||
Ok(vault::filter_gitignored_folders(
|
||||
vault_path,
|
||||
folders,
|
||||
crate::settings::hide_gitignored_files_enabled(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Sync the `title` frontmatter field with the filename on note open.
|
||||
/// Returns `true` if the file was modified (title was absent or desynced).
|
||||
#[tauri::command]
|
||||
@@ -146,25 +243,193 @@ pub fn sync_note_title(path: PathBuf, vault_path: Option<PathBuf>) -> Result<boo
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_image(vault_path: PathBuf, filename: String, data: String) -> Result<String, String> {
|
||||
with_requested_root_path(vault_path.as_path(), |requested_root| {
|
||||
pub fn save_image(
|
||||
app_handle: tauri::AppHandle,
|
||||
vault_path: PathBuf,
|
||||
filename: String,
|
||||
data: String,
|
||||
) -> Result<String, String> {
|
||||
with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| {
|
||||
vault::save_image(requested_root, &filename, &data)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn copy_image_to_vault(vault_path: PathBuf, source_path: PathBuf) -> Result<String, String> {
|
||||
with_requested_root_path(vault_path.as_path(), |requested_root| {
|
||||
pub fn copy_image_to_vault(
|
||||
app_handle: tauri::AppHandle,
|
||||
vault_path: PathBuf,
|
||||
source_path: PathBuf,
|
||||
) -> Result<String, String> {
|
||||
with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| {
|
||||
vault::copy_image_to_vault(requested_root, source_path.to_string_lossy().as_ref())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
|
||||
with_expanded_vault_root(path.as_path(), vault::scan_vault_cached)
|
||||
with_expanded_vault_root(path.as_path(), scan_visible_vault_entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
|
||||
with_expanded_vault_root(path.as_path(), vault::scan_vault_folders)
|
||||
with_expanded_vault_root(path.as_path(), scan_visible_vault_folders)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn vault_root(dir: &TempDir) -> PathBuf {
|
||||
dir.path().to_path_buf()
|
||||
}
|
||||
|
||||
fn note_path(dir: &TempDir, name: &str) -> PathBuf {
|
||||
dir.path().join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_content_commands_roundtrip_with_requested_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = vault_root(&dir);
|
||||
let note = note_path(&dir, "notes/command-note.md");
|
||||
|
||||
create_note_content(
|
||||
note.clone(),
|
||||
"# Command Note\n".to_string(),
|
||||
Some(root.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
get_note_content(note.clone(), Some(root.clone())).unwrap(),
|
||||
"# Command Note\n"
|
||||
);
|
||||
|
||||
save_note_content(
|
||||
note.clone(),
|
||||
"---\ntitle: Command Note\n---\n# Command Note\nBody\n".to_string(),
|
||||
Some(root.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!sync_note_title(note.clone(), Some(root.clone())).unwrap());
|
||||
|
||||
save_note_content(
|
||||
note.clone(),
|
||||
"# Updated Command Note\n".to_string(),
|
||||
Some(root.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(sync_note_title(note.clone(), Some(root.clone())).unwrap());
|
||||
assert!(get_note_content(note, Some(root))
|
||||
.unwrap()
|
||||
.contains("title: Command Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_content_commands_accept_windows_sensitive_valid_segments() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = vault_root(&dir);
|
||||
let note = root
|
||||
.join("@raflymln")
|
||||
.join("notes with spaces")
|
||||
.join("résumé note.md");
|
||||
|
||||
save_note_content(
|
||||
note.clone(),
|
||||
"# Windows-Sensitive Path\n\nBody\n".to_string(),
|
||||
Some(root.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
get_note_content(note, Some(root)).unwrap(),
|
||||
"# Windows-Sensitive Path\n\nBody\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_and_listing_commands_use_expanded_vault_root() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = vault_root(&dir);
|
||||
fs::write(dir.path().join("root.md"), "# Root\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
create_vault_folder(root.clone(), PathBuf::from("Projects")).unwrap(),
|
||||
"Projects"
|
||||
);
|
||||
fs::write(dir.path().join("Projects/project.md"), "# Project\n").unwrap();
|
||||
|
||||
let entries = list_vault(root.clone()).unwrap();
|
||||
assert!(entries.iter().any(|entry| entry.filename == "root.md"));
|
||||
assert!(entries.iter().any(|entry| entry.filename == "project.md"));
|
||||
|
||||
let folders = list_vault_folders(root).unwrap();
|
||||
assert!(folders.iter().any(|folder| folder.name == "Projects"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_reject_paths_outside_requested_vault() {
|
||||
let vault = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let outside_note = outside.path().join("outside.md");
|
||||
fs::write(&outside_note, "# Outside\n").unwrap();
|
||||
|
||||
let error = get_note_content(outside_note, Some(vault.path().to_path_buf())).unwrap_err();
|
||||
assert!(error.contains("Path must stay inside the active vault"));
|
||||
|
||||
let folder_error =
|
||||
create_vault_folder(vault.path().to_path_buf(), PathBuf::from("../escape"))
|
||||
.unwrap_err();
|
||||
assert!(folder_error.contains("Path must stay inside the active vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_file_paths_accept_files_inside_requested_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = vault_root(&dir);
|
||||
let attachment = note_path(&dir, "attachments/photo.png");
|
||||
fs::create_dir_all(attachment.parent().unwrap()).unwrap();
|
||||
fs::write(&attachment, "image-bytes").unwrap();
|
||||
|
||||
let validated = with_external_file_path(
|
||||
attachment.as_path(),
|
||||
Some(root.as_path()),
|
||||
|validated_path| Ok(validated_path.to_path_buf()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(validated, attachment);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_file_paths_reject_files_outside_requested_vault() {
|
||||
let vault = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let outside_file = outside.path().join("photo.png");
|
||||
fs::write(&outside_file, "image-bytes").unwrap();
|
||||
|
||||
let error = with_external_file_path(
|
||||
outside_file.as_path(),
|
||||
Some(vault.path()),
|
||||
|validated_path| Ok(validated_path.to_path_buf()),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.contains("Path must stay inside the active vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_note_content_compares_against_disk() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = vault_root(&dir);
|
||||
let note = note_path(&dir, "note.md");
|
||||
fs::write(¬e, "# Fresh\n").unwrap();
|
||||
|
||||
assert!(
|
||||
validate_note_content(note.clone(), "# Fresh\n".to_string(), Some(root.clone()),)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(!validate_note_content(note, "# Stale\n".to_string(), Some(root)).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +57,11 @@ fn canonical_vault_path_string(vault_dir: &Path) -> String {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
|
||||
pub async fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
|
||||
let path = resolve_getting_started_target(target_path.as_deref())?;
|
||||
vault::create_getting_started_vault(&path)
|
||||
tokio::task::spawn_blocking(move || vault::create_getting_started_vault(&path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
fn resolve_getting_started_target(target_path: Option<&str>) -> Result<String, String> {
|
||||
|
||||
@@ -17,12 +17,12 @@ pub fn rename_note(
|
||||
&vault_path,
|
||||
&old_path,
|
||||
|requested_root, validated_path| {
|
||||
vault::rename_note(
|
||||
requested_root,
|
||||
validated_path,
|
||||
&new_title,
|
||||
old_title.as_deref(),
|
||||
)
|
||||
vault::rename_note(vault::RenameNoteRequest {
|
||||
vault_path: requested_root,
|
||||
old_path: validated_path,
|
||||
new_title: &new_title,
|
||||
old_title_hint: old_title.as_deref(),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -37,7 +37,11 @@ pub fn rename_note_filename(
|
||||
&vault_path,
|
||||
&old_path,
|
||||
|requested_root, validated_path| {
|
||||
vault::rename_note_filename(requested_root, validated_path, &new_filename_stem)
|
||||
vault::rename_note_filename(vault::RenameNoteFilenameRequest {
|
||||
vault_path: requested_root,
|
||||
old_path: validated_path,
|
||||
new_filename_stem: &new_filename_stem,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -67,11 +71,11 @@ pub fn move_note_to_folder(
|
||||
if !validated_folder.is_dir() {
|
||||
return Err(format!("Folder does not exist: {}", trimmed_folder_path));
|
||||
}
|
||||
vault::move_note_to_folder(
|
||||
requested_root,
|
||||
validated_path,
|
||||
validated_folder_path,
|
||||
)
|
||||
vault::move_note_to_folder(vault::MoveNoteToFolderRequest {
|
||||
vault_path: requested_root,
|
||||
old_path: validated_path,
|
||||
destination_folder_path: validated_folder_path,
|
||||
})
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -87,7 +91,10 @@ pub fn auto_rename_untitled(
|
||||
&vault_path,
|
||||
¬e_path,
|
||||
|requested_root, validated_path| {
|
||||
vault::auto_rename_untitled(requested_root, validated_path)
|
||||
vault::auto_rename_untitled(vault::AutoRenameUntitledRequest {
|
||||
vault_path: requested_root,
|
||||
note_path: validated_path,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -95,7 +102,7 @@ pub fn auto_rename_untitled(
|
||||
#[tauri::command]
|
||||
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::detect_renames(&vault_path)
|
||||
vault::detect_renames(Path::new(vault_path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -104,5 +111,120 @@ pub fn update_wikilinks_for_renames(
|
||||
renames: Vec<DetectedRename>,
|
||||
) -> Result<usize, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::update_wikilinks_for_renames(&vault_path, &renames)
|
||||
vault::update_wikilinks_for_renames(Path::new(vault_path.as_ref()), &renames)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn vault_path(dir: &TempDir) -> String {
|
||||
dir.path().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn write_note(dir: &TempDir, relative_path: &str, content: &str) -> String {
|
||||
let path = dir.path().join(relative_path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&path, content).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_note_command_updates_title_file_and_links() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = vault_path(&dir);
|
||||
let old_path = write_note(
|
||||
&dir,
|
||||
"old-title.md",
|
||||
"---\ntitle: Old Title\n---\n# Old Title\n",
|
||||
);
|
||||
let linked_path = write_note(&dir, "linked.md", "See [[Old Title]].\n");
|
||||
|
||||
let result = rename_note(
|
||||
vault.clone(),
|
||||
old_path.clone(),
|
||||
"New Title".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.new_path.ends_with("new-title.md"));
|
||||
assert!(!Path::new(&old_path).exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
assert!(fs::read_to_string(linked_path)
|
||||
.unwrap()
|
||||
.contains("[[new-title]]"));
|
||||
assert_eq!(result.failed_updates, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_and_folder_commands_preserve_note_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = vault_path(&dir);
|
||||
let old_path = write_note(
|
||||
&dir,
|
||||
"draft.md",
|
||||
"---\ntitle: Draft Title\n---\n# Draft Title\n",
|
||||
);
|
||||
|
||||
let renamed =
|
||||
rename_note_filename(vault.clone(), old_path, "custom-name".to_string()).unwrap();
|
||||
assert!(renamed.new_path.ends_with("custom-name.md"));
|
||||
|
||||
fs::create_dir(dir.path().join("Projects")).unwrap();
|
||||
let moved = move_note_to_folder(
|
||||
vault.clone(),
|
||||
renamed.new_path.clone(),
|
||||
"Projects".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(moved.new_path.ends_with("Projects/custom-name.md"));
|
||||
assert!(fs::read_to_string(moved.new_path)
|
||||
.unwrap()
|
||||
.contains("Draft Title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_rename_and_detected_rename_commands_route_through_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = vault_path(&dir);
|
||||
let untitled = write_note(&dir, "untitled-note-123.md", "# Project Plan\n");
|
||||
|
||||
let auto = auto_rename_untitled(vault.clone(), untitled)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(auto.new_path.ends_with("project-plan.md"));
|
||||
|
||||
crate::git::init_repo(&vault).unwrap();
|
||||
let old_path = dir.path().join("project-plan.md");
|
||||
let new_path = dir.path().join("plans.md");
|
||||
fs::rename(&old_path, &new_path).unwrap();
|
||||
crate::hidden_command("git")
|
||||
.args(["add", "-A"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let renames = detect_renames(vault.clone()).unwrap();
|
||||
assert_eq!(renames.len(), 1);
|
||||
assert_eq!(renames[0].old_path, "project-plan.md");
|
||||
assert_eq!(renames[0].new_path, "plans.md");
|
||||
|
||||
assert_eq!(update_wikilinks_for_renames(vault, renames).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_note_to_folder_rejects_empty_folder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = vault_path(&dir);
|
||||
let note = write_note(&dir, "note.md", "# Note\n");
|
||||
|
||||
let error = move_note_to_folder(vault, note, " ".to_string()).unwrap_err();
|
||||
assert!(error.contains("Folder path cannot be empty"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,72 @@
|
||||
use crate::commands::expand_tilde;
|
||||
use crate::search::SearchResponse;
|
||||
use crate::vault::VaultEntry;
|
||||
use crate::{search, vault};
|
||||
use crate::{search, vault, vault_list};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::boundary::{with_validated_path, ValidatedPathMode};
|
||||
|
||||
fn collect_registered_vault_roots(vault_list: &vault_list::VaultList) -> Vec<PathBuf> {
|
||||
let mut roots = vault_list
|
||||
.vaults
|
||||
.iter()
|
||||
.map(|entry| PathBuf::from(expand_tilde(&entry.path).into_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(active_vault) = &vault_list.active_vault {
|
||||
roots.push(PathBuf::from(expand_tilde(active_vault).into_owned()));
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
|
||||
fn find_registered_vault_root(path: &Path, registered_roots: &[PathBuf]) -> Option<PathBuf> {
|
||||
registered_roots
|
||||
.iter()
|
||||
.filter_map(|root| {
|
||||
let canonical_root = root.canonicalize().ok()?;
|
||||
path.starts_with(&canonical_root)
|
||||
.then_some((canonical_root.components().count(), root.clone()))
|
||||
})
|
||||
.max_by_key(|(depth, _)| *depth)
|
||||
.map(|(_, root)| root)
|
||||
}
|
||||
|
||||
fn resolve_reload_vault_path(
|
||||
path: &Path,
|
||||
vault_path: Option<&Path>,
|
||||
) -> Result<Option<PathBuf>, String> {
|
||||
if let Some(vault_path) = vault_path {
|
||||
return Ok(Some(vault_path.to_path_buf()));
|
||||
}
|
||||
|
||||
if !path.is_absolute() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let canonical_path = match path.canonicalize() {
|
||||
Ok(canonical_path) => canonical_path,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
let vault_list = vault_list::load_vault_list()?;
|
||||
let registered_roots = collect_registered_vault_roots(&vault_list);
|
||||
Ok(find_registered_vault_root(
|
||||
canonical_path.as_path(),
|
||||
®istered_roots,
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn reload_vault_entry(
|
||||
path: PathBuf,
|
||||
vault_path: Option<PathBuf>,
|
||||
) -> Result<VaultEntry, String> {
|
||||
let resolved_vault_path = resolve_reload_vault_path(path.as_path(), vault_path.as_deref())?;
|
||||
let raw_path = path.to_string_lossy();
|
||||
let raw_vault_path = vault_path.as_ref().map(|value| value.to_string_lossy());
|
||||
let raw_vault_path = resolved_vault_path
|
||||
.as_ref()
|
||||
.map(|value| value.to_string_lossy().into_owned());
|
||||
with_validated_path(
|
||||
&raw_path,
|
||||
raw_vault_path.as_deref(),
|
||||
@@ -29,8 +83,14 @@ pub async fn reload_vault(
|
||||
let path = expand_tilde(&path).into_owned();
|
||||
crate::sync_vault_asset_scope(&app_handle, Path::new(&path))?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
vault::invalidate_cache(Path::new(&path));
|
||||
vault::scan_vault_cached(Path::new(&path))
|
||||
let vault_path = Path::new(&path);
|
||||
vault::invalidate_cache(vault_path);
|
||||
let entries = vault::scan_vault_cached(vault_path)?;
|
||||
Ok(vault::filter_gitignored_entries(
|
||||
vault_path,
|
||||
entries,
|
||||
crate::settings::hide_gitignored_files_enabled(),
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
@@ -49,3 +109,68 @@ pub async fn search_vault(
|
||||
.await
|
||||
.map_err(|e| format!("Search task failed: {}", e))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{collect_registered_vault_roots, find_registered_vault_root};
|
||||
use crate::vault_list::{VaultEntry as VaultListEntry, VaultList};
|
||||
|
||||
#[test]
|
||||
fn finds_registered_vault_root_for_an_absolute_note_path() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_root = dir.path().join("vault");
|
||||
let note_path = vault_root.join("note.md");
|
||||
std::fs::create_dir_all(&vault_root).unwrap();
|
||||
std::fs::write(¬e_path, "# Note\n").unwrap();
|
||||
|
||||
let vault_list = VaultList {
|
||||
vaults: vec![VaultListEntry {
|
||||
label: "Test".to_string(),
|
||||
path: vault_root.to_string_lossy().into_owned(),
|
||||
}],
|
||||
active_vault: None,
|
||||
hidden_defaults: vec![],
|
||||
};
|
||||
|
||||
let registered_roots = collect_registered_vault_roots(&vault_list);
|
||||
let canonical_note_path = note_path.canonicalize().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
find_registered_vault_root(canonical_note_path.as_path(), ®istered_roots),
|
||||
Some(vault_root),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_the_deepest_registered_vault_root() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let parent_root = dir.path().join("vault");
|
||||
let nested_root = parent_root.join("projects");
|
||||
let note_path = nested_root.join("note.md");
|
||||
std::fs::create_dir_all(&nested_root).unwrap();
|
||||
std::fs::write(¬e_path, "# Note\n").unwrap();
|
||||
|
||||
let vault_list = VaultList {
|
||||
vaults: vec![
|
||||
VaultListEntry {
|
||||
label: "Parent".to_string(),
|
||||
path: parent_root.to_string_lossy().into_owned(),
|
||||
},
|
||||
VaultListEntry {
|
||||
label: "Nested".to_string(),
|
||||
path: nested_root.to_string_lossy().into_owned(),
|
||||
},
|
||||
],
|
||||
active_vault: None,
|
||||
hidden_defaults: vec![],
|
||||
};
|
||||
|
||||
let registered_roots = collect_registered_vault_roots(&vault_list);
|
||||
let canonical_note_path = note_path.canonicalize().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
find_registered_vault_root(canonical_note_path.as_path(), ®istered_roots),
|
||||
Some(nested_root),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
220
src-tauri/src/gemini_cli.rs
Normal file
220
src-tauri/src/gemini_cli.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
pub use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
use std::process::Output;
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
crate::gemini_discovery::check_cli()
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = crate::gemini_discovery::find_binary()?;
|
||||
run_agent_stream_with_binary(&binary, request, emit)
|
||||
}
|
||||
|
||||
fn run_agent_stream_with_binary<F>(
|
||||
binary: &Path,
|
||||
request: AgentStreamRequest,
|
||||
mut emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let settings_dir = tempfile::Builder::new()
|
||||
.prefix("tolaria-gemini-agent-")
|
||||
.tempdir()
|
||||
.map_err(|error| format!("Failed to create Gemini settings directory: {error}"))?;
|
||||
let mut command = crate::gemini_config::build_command(binary, &request, settings_dir.path())?;
|
||||
let output = command
|
||||
.output()
|
||||
.map_err(|error| format!("Failed to spawn gemini: {error}"))?;
|
||||
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: "gemini-headless".into(),
|
||||
});
|
||||
|
||||
if output.status.success() {
|
||||
emit_gemini_success(&output, &mut emit);
|
||||
} else {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format_gemini_error(output_stderr(&output), output.status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok("gemini-headless".into())
|
||||
}
|
||||
|
||||
fn emit_gemini_success<F>(output: &Output, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
match gemini_response_text(&output_stdout(output)) {
|
||||
GeminiOutput::Response(text) => emit(AiAgentStreamEvent::TextDelta { text }),
|
||||
GeminiOutput::Error(message) => emit(AiAgentStreamEvent::Error { message }),
|
||||
GeminiOutput::Empty => {}
|
||||
}
|
||||
}
|
||||
|
||||
enum GeminiOutput {
|
||||
Response(String),
|
||||
Error(String),
|
||||
Empty,
|
||||
}
|
||||
|
||||
fn gemini_response_text(stdout: &str) -> GeminiOutput {
|
||||
let trimmed = stdout.trim();
|
||||
if trimmed.is_empty() {
|
||||
return GeminiOutput::Empty;
|
||||
}
|
||||
|
||||
match serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
Ok(json) => response_from_json(&json),
|
||||
Err(_) => GeminiOutput::Response(trimmed.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn response_from_json(json: &serde_json::Value) -> GeminiOutput {
|
||||
if let Some(message) = json["error"]["message"].as_str() {
|
||||
return GeminiOutput::Error(message.to_string());
|
||||
}
|
||||
if let Some(response) = json["response"].as_str() {
|
||||
return GeminiOutput::Response(response.to_string());
|
||||
}
|
||||
GeminiOutput::Empty
|
||||
}
|
||||
|
||||
fn output_stdout(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stdout).to_string()
|
||||
}
|
||||
|
||||
fn output_stderr(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stderr).to_string()
|
||||
}
|
||||
|
||||
fn format_gemini_error(stderr_output: String, status: String) -> String {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
if is_auth_error(&lower) {
|
||||
return "Gemini CLI is not authenticated. Run `gemini` in your terminal to sign in, or set GEMINI_API_KEY and retry.".into();
|
||||
}
|
||||
|
||||
if stderr_output.trim().is_empty() {
|
||||
format!("gemini exited with status {status}")
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_auth_error(lower: &str) -> bool {
|
||||
[
|
||||
"auth",
|
||||
"login",
|
||||
"sign in",
|
||||
"api key",
|
||||
"gemini_api_key",
|
||||
"google_api_key",
|
||||
"oauth",
|
||||
"401",
|
||||
]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let script = dir.join("gemini");
|
||||
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
script
|
||||
}
|
||||
|
||||
fn request(vault_path: String) -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
message: "Summarize".into(),
|
||||
system_prompt: None,
|
||||
vault_path,
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_agent_stream_maps_gemini_json_response() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
r#"printf '%s\n' '{"response":"Done","stats":{"tools":{"totalCalls":0}}}'"#,
|
||||
);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let session_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
request(vault.path().to_string_lossy().into_owned()),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session_id, "gemini-headless");
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Init { session_id } if session_id == "gemini-headless"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "Done"
|
||||
));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_agent_stream_reports_gemini_auth_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
r#"printf '%s\n' 'oauth login required' >&2
|
||||
exit 3
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let session_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
request(vault.path().to_string_lossy().into_owned()),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session_id, "gemini-headless");
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
|
||||
)));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_response_text_reads_json_response_or_plain_text() {
|
||||
match gemini_response_text(r#"{"response":"Structured"}"#) {
|
||||
GeminiOutput::Response(text) => assert_eq!(text, "Structured"),
|
||||
_ => panic!("expected response"),
|
||||
}
|
||||
|
||||
match gemini_response_text("Plain answer") {
|
||||
GeminiOutput::Response(text) => assert_eq!(text, "Plain answer"),
|
||||
_ => panic!("expected response"),
|
||||
}
|
||||
}
|
||||
}
|
||||
164
src-tauri/src/gemini_config.rs
Normal file
164
src-tauri/src/gemini_config.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
use crate::gemini_cli::AgentStreamRequest;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
|
||||
pub(crate) fn build_command(
|
||||
binary: &Path,
|
||||
request: &AgentStreamRequest,
|
||||
settings_dir: &Path,
|
||||
) -> Result<std::process::Command, String> {
|
||||
let settings_path = write_settings(settings_dir, &request.vault_path, request.permission_mode)?;
|
||||
let mut command = crate::hidden_command(binary);
|
||||
command
|
||||
.args(build_args(request.permission_mode))
|
||||
.arg("--prompt")
|
||||
.arg(build_prompt(request))
|
||||
.env("GEMINI_CLI_SYSTEM_SETTINGS_PATH", settings_path)
|
||||
.env("NO_COLOR", "1")
|
||||
.current_dir(&request.vault_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn build_args(permission_mode: AiAgentPermissionMode) -> Vec<String> {
|
||||
vec![
|
||||
"--output-format".into(),
|
||||
"json".into(),
|
||||
"--approval-mode".into(),
|
||||
approval_mode(permission_mode).into(),
|
||||
]
|
||||
}
|
||||
|
||||
fn approval_mode(permission_mode: AiAgentPermissionMode) -> &'static str {
|
||||
match permission_mode {
|
||||
AiAgentPermissionMode::Safe => "auto_edit",
|
||||
AiAgentPermissionMode::PowerUser => "yolo",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_prompt(request: &AgentStreamRequest) -> String {
|
||||
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
|
||||
}
|
||||
|
||||
fn write_settings(
|
||||
settings_dir: &Path,
|
||||
vault_path: &str,
|
||||
permission_mode: AiAgentPermissionMode,
|
||||
) -> Result<PathBuf, String> {
|
||||
std::fs::create_dir_all(settings_dir)
|
||||
.map_err(|error| format!("Failed to create Gemini settings directory: {error}"))?;
|
||||
let settings_path = settings_dir.join("settings.json");
|
||||
let settings = build_settings(vault_path, permission_mode)?;
|
||||
std::fs::write(&settings_path, settings)
|
||||
.map_err(|error| format!("Failed to write Gemini settings: {error}"))?;
|
||||
Ok(settings_path)
|
||||
}
|
||||
|
||||
fn build_settings(
|
||||
vault_path: &str,
|
||||
permission_mode: AiAgentPermissionMode,
|
||||
) -> Result<String, String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
let mut settings = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"tolaria": {
|
||||
"command": "node",
|
||||
"args": [mcp_server_path],
|
||||
"env": {
|
||||
"VAULT_PATH": vault_path,
|
||||
"WS_UI_PORT": "9711"
|
||||
},
|
||||
"description": "Tolaria active vault MCP server",
|
||||
"trust": permission_mode == AiAgentPermissionMode::PowerUser
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if permission_mode == AiAgentPermissionMode::Safe {
|
||||
settings["tools"] = serde_json::json!({
|
||||
"exclude": ["run_shell_command"]
|
||||
});
|
||||
}
|
||||
|
||||
serde_json::to_string(&settings)
|
||||
.map_err(|error| format!("Failed to serialize Gemini settings: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
fn request() -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_uses_headless_json_mode_and_temp_settings() {
|
||||
let settings_dir = tempfile::tempdir().unwrap();
|
||||
let command =
|
||||
build_command(&PathBuf::from("gemini"), &request(), settings_dir.path()).unwrap();
|
||||
let actual_args: Vec<&OsStr> = command.get_args().collect();
|
||||
let settings_path = command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == OsStr::new("GEMINI_CLI_SYSTEM_SETTINGS_PATH"))
|
||||
.and_then(|(_, value)| value);
|
||||
|
||||
assert_eq!(command.get_program(), OsStr::new("gemini"));
|
||||
assert_eq!(actual_args[0], OsStr::new("--output-format"));
|
||||
assert_eq!(actual_args[1], OsStr::new("json"));
|
||||
assert!(actual_args.contains(&OsStr::new("--prompt")));
|
||||
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
|
||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
|
||||
assert!(settings_path.is_some());
|
||||
assert!(settings_dir.path().join("settings.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_settings_include_tolaria_mcp_and_exclude_shell() {
|
||||
let settings = build_settings("/tmp/vault", AiAgentPermissionMode::Safe).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&settings).unwrap();
|
||||
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
|
||||
assert_eq!(
|
||||
json["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
|
||||
"/tmp/vault"
|
||||
);
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"], "9711");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["trust"], false);
|
||||
assert_eq!(json["tools"]["exclude"][0], "run_shell_command");
|
||||
assert!(json["mcpServers"]["tolaria"]["args"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with("index.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_user_settings_trust_tolaria_and_allow_shell_discovery() {
|
||||
let settings = build_settings("/tmp/vault", AiAgentPermissionMode::PowerUser).unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&settings).unwrap();
|
||||
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["trust"], true);
|
||||
assert!(json.get("tools").is_none());
|
||||
assert_eq!(approval_mode(AiAgentPermissionMode::PowerUser), "yolo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_prompt(&AgentStreamRequest {
|
||||
system_prompt: Some("Be concise".into()),
|
||||
..request()
|
||||
});
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
}
|
||||
187
src-tauri/src/gemini_discovery.rs
Normal file
187
src-tauri/src/gemini_discovery.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use crate::ai_agents::AiAgentAvailability;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn check_cli() -> AiAgentAvailability {
|
||||
match find_binary() {
|
||||
Ok(binary) => AiAgentAvailability {
|
||||
installed: true,
|
||||
version: crate::cli_agent_runtime::version_for_binary(&binary),
|
||||
},
|
||||
Err(_) => AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
if let Some(binary) = find_binary_in_user_shell() {
|
||||
return Ok(binary);
|
||||
}
|
||||
if let Some(binary) = find_existing_binary(gemini_binary_candidates()) {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
Err("Gemini CLI not found. Install it: https://google-gemini.github.io/gemini-cli/".into())
|
||||
}
|
||||
|
||||
fn find_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command(path_lookup_command())
|
||||
.arg("gemini")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_lookup_command() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
}
|
||||
}
|
||||
|
||||
fn find_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, "gemini"))
|
||||
}
|
||||
|
||||
fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
let mut shells = Vec::new();
|
||||
if let Some(shell) = std::env::var_os("SHELL") {
|
||||
if !shell.is_empty() {
|
||||
shells.push(PathBuf::from(shell));
|
||||
}
|
||||
}
|
||||
shells.push(PathBuf::from("/bin/zsh"));
|
||||
shells.push(PathBuf::from("/bin/bash"));
|
||||
shells
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
||||
if output.status.success() {
|
||||
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
candidates.into_iter().find(|candidate| candidate.exists())
|
||||
}
|
||||
|
||||
fn gemini_binary_candidates() -> Vec<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.map(|home| gemini_binary_candidates_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn gemini_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
home.join(".local/bin/gemini"),
|
||||
home.join(".local/bin/gemini.exe"),
|
||||
home.join(".gemini/bin/gemini"),
|
||||
home.join(".gemini/bin/gemini.exe"),
|
||||
home.join(".local/share/mise/shims/gemini"),
|
||||
home.join(".local/share/mise/shims/gemini.exe"),
|
||||
home.join(".asdf/shims/gemini"),
|
||||
home.join(".asdf/shims/gemini.exe"),
|
||||
home.join(".npm-global/bin/gemini"),
|
||||
home.join(".npm-global/bin/gemini.cmd"),
|
||||
home.join(".npm-global/bin/gemini.exe"),
|
||||
home.join(".npm/bin/gemini"),
|
||||
home.join(".npm/bin/gemini.cmd"),
|
||||
home.join(".npm/bin/gemini.exe"),
|
||||
home.join(".bun/bin/gemini"),
|
||||
home.join(".bun/bin/gemini.exe"),
|
||||
home.join("AppData/Roaming/npm/gemini.cmd"),
|
||||
home.join("AppData/Roaming/npm/gemini.exe"),
|
||||
home.join("AppData/Local/pnpm/gemini.cmd"),
|
||||
home.join("AppData/Local/pnpm/gemini.exe"),
|
||||
home.join("scoop/shims/gemini.exe"),
|
||||
PathBuf::from("/usr/local/bin/gemini"),
|
||||
PathBuf::from("/opt/homebrew/bin/gemini"),
|
||||
];
|
||||
candidates.extend(nvm_binary_candidates_for_home(home));
|
||||
candidates
|
||||
}
|
||||
|
||||
fn nvm_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut candidates = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.map(|path| path.join("bin").join("gemini"))
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort();
|
||||
candidates
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_supported_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = gemini_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/gemini"),
|
||||
home.join(".gemini/bin/gemini"),
|
||||
home.join(".local/share/mise/shims/gemini"),
|
||||
home.join(".asdf/shims/gemini"),
|
||||
home.join(".npm-global/bin/gemini"),
|
||||
home.join(".bun/bin/gemini"),
|
||||
PathBuf::from("/opt/homebrew/bin/gemini"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("missing-gemini");
|
||||
let gemini = dir.path().join("gemini");
|
||||
std::fs::write(&gemini, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let stdout = format!("\n{}\n{}\n", missing.display(), gemini.display());
|
||||
|
||||
assert_eq!(first_existing_path(&stdout), Some(gemini));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
use super::git_command;
|
||||
|
||||
struct CloneRequest<'a> {
|
||||
url: &'a str,
|
||||
dest: &'a Path,
|
||||
}
|
||||
|
||||
/// Clone a git repository to a local path using the system git configuration.
|
||||
pub fn clone_repo(url: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
let request = CloneRequest { url, dest };
|
||||
prepare_clone_destination(dest)?;
|
||||
|
||||
if let Err(err) = run_clone(url, dest) {
|
||||
if let Err(err) = run_clone(&request) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
@@ -64,12 +72,14 @@ fn directory_has_entries(dest: &Path) -> Result<bool, String> {
|
||||
.map(|mut entries| entries.next().is_some())
|
||||
}
|
||||
|
||||
fn run_clone(url: &str, dest: &Path) -> Result<(), String> {
|
||||
let destination = dest
|
||||
.to_str()
|
||||
.ok_or_else(|| format!("Destination '{}' is not valid UTF-8", dest.display()))?;
|
||||
let output = Command::new("git")
|
||||
.args(["clone", "--progress", url, destination])
|
||||
fn run_clone(request: &CloneRequest<'_>) -> Result<(), String> {
|
||||
let destination = request.dest.to_str().ok_or_else(|| {
|
||||
format!(
|
||||
"Destination '{}' is not valid UTF-8",
|
||||
request.dest.display()
|
||||
)
|
||||
})?;
|
||||
let output = build_clone_command(request, destination)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
@@ -77,8 +87,36 @@ fn run_clone(url: &str, dest: &Path) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"git clone failed: {}",
|
||||
clone_failure_message(&output)
|
||||
))
|
||||
}
|
||||
|
||||
fn build_clone_command(request: &CloneRequest<'_>, destination: &str) -> Command {
|
||||
let mut command = git_command();
|
||||
command
|
||||
.args(["clone", "--quiet", request.url, destination])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("SSH_ASKPASS_REQUIRE", "never")
|
||||
.stdin(Stdio::null());
|
||||
command
|
||||
}
|
||||
|
||||
fn clone_failure_message(output: &Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("git clone failed: {}", stderr.trim()))
|
||||
let stderr = stderr.trim();
|
||||
if !stderr.is_empty() {
|
||||
return stderr.to_string();
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stdout = stdout.trim();
|
||||
if !stdout.is_empty() {
|
||||
return stdout.to_string();
|
||||
}
|
||||
|
||||
format!("git clone exited with status {}", output.status)
|
||||
}
|
||||
|
||||
fn cleanup_failed_clone(dest: &Path) {
|
||||
@@ -91,6 +129,7 @@ fn cleanup_failed_clone(dest: &Path) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::path::Path;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
@@ -160,4 +199,56 @@ mod tests {
|
||||
);
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_failure_message_falls_back_to_stdout() {
|
||||
let output = Output {
|
||||
status: std::process::ExitStatus::from_raw(128),
|
||||
stdout: b"fatal: stdout only".to_vec(),
|
||||
stderr: Vec::new(),
|
||||
};
|
||||
|
||||
assert_eq!(clone_failure_message(&output), "fatal: stdout only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_clone_command_disables_interactive_prompts() {
|
||||
let dest = Path::new("/tmp/repo");
|
||||
let request = CloneRequest {
|
||||
url: "https://example.com/repo.git",
|
||||
dest,
|
||||
};
|
||||
let command = build_clone_command(&request, "/tmp/repo");
|
||||
let args = command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let envs = command
|
||||
.get_envs()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key.to_string_lossy().to_string(),
|
||||
value.map(|entry| entry.to_string_lossy().to_string()),
|
||||
)
|
||||
})
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
assert_eq!(
|
||||
envs.get("GIT_TERMINAL_PROMPT"),
|
||||
Some(&Some("0".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
envs.get("SSH_ASKPASS_REQUIRE"),
|
||||
Some(&Some("never".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"clone".to_string(),
|
||||
"--quiet".to_string(),
|
||||
"https://example.com/repo.git".to_string(),
|
||||
"/tmp/repo".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::git_command;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
struct CommitFailure {
|
||||
stdout: String,
|
||||
@@ -11,7 +11,7 @@ pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
// Stage all changes
|
||||
let add = Command::new("git")
|
||||
let add = git_command()
|
||||
.args(["add", "-A"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -37,7 +37,7 @@ pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
|
||||
}
|
||||
|
||||
fn run_commit(vault: &Path, message: &str, disable_signing: bool) -> Result<String, CommitFailure> {
|
||||
let mut command = Command::new("git");
|
||||
let mut command = git_command();
|
||||
if disable_signing {
|
||||
command.args(["-c", "commit.gpgsign=false"]);
|
||||
}
|
||||
@@ -85,10 +85,10 @@ fn is_commit_signing_failure(detail: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::git_command;
|
||||
use super::*;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_git_commit() {
|
||||
@@ -101,7 +101,7 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify the commit exists
|
||||
let log = Command::new("git")
|
||||
let log = git_command()
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -135,12 +135,12 @@ mod tests {
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["config", "commit.gpgsign", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["config", "gpg.program", "/missing/tolaria-test-gpg"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -153,14 +153,14 @@ mod tests {
|
||||
"commit should retry unsigned when signing helper is missing: {result:?}"
|
||||
);
|
||||
|
||||
let log = Command::new("git")
|
||||
let log = git_command()
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&log.stdout).contains("Commit with broken signing config"));
|
||||
|
||||
let config = Command::new("git")
|
||||
let config = git_command()
|
||||
.args(["config", "commit.gpgsign"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::run_git;
|
||||
use super::{git_command, run_git};
|
||||
|
||||
/// List files with merge conflicts (unmerged paths).
|
||||
///
|
||||
@@ -10,7 +9,7 @@ use super::run_git;
|
||||
/// stale (e.g. after a reboot or when MERGE_HEAD is missing).
|
||||
pub fn get_conflict_files(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["ls-files", "--unmerged"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -93,13 +92,13 @@ pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String
|
||||
|
||||
let mode = get_conflict_mode(vault_path);
|
||||
let output = match mode.as_str() {
|
||||
"rebase" => Command::new("git")
|
||||
"rebase" => git_command()
|
||||
.args(["rebase", "--continue"])
|
||||
.env("GIT_EDITOR", "true")
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git rebase --continue: {}", e))?,
|
||||
_ => Command::new("git")
|
||||
_ => git_command()
|
||||
.args(["commit", "-m", "Resolve merge conflicts"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -131,7 +130,6 @@ mod tests {
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair};
|
||||
use crate::git::{git_commit, git_pull, git_push};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
@@ -203,35 +201,30 @@ mod tests {
|
||||
(bare_dir, clone_a_dir, clone_b_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_ours() {
|
||||
fn assert_resolve_conflict_strategy(strategy: &str, expected_content: &str) {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let conflicts = get_conflict_files(vp_b).unwrap();
|
||||
assert!(conflicts.contains(&"conflict.md".to_string()));
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
||||
git_resolve_conflict(vp_b, "conflict.md", strategy).unwrap();
|
||||
|
||||
let remaining = get_conflict_files(vp_b).unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
|
||||
assert_eq!(content, "# Version B\n");
|
||||
assert_eq!(content, expected_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_ours() {
|
||||
assert_resolve_conflict_strategy("ours", "# Version B\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_theirs() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
|
||||
|
||||
let remaining = get_conflict_files(vp_b).unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
|
||||
assert_eq!(content, "# Version A\n");
|
||||
assert_resolve_conflict_strategy("theirs", "# Version A\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -244,7 +237,7 @@ mod tests {
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let log = Command::new("git")
|
||||
let log = git_command()
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(clone_b.path())
|
||||
.output()
|
||||
@@ -310,7 +303,7 @@ mod tests {
|
||||
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
|
||||
git_commit(vp_b, "B's change").unwrap();
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["pull", "--rebase"])
|
||||
.current_dir(clone_b_dir.path())
|
||||
.output()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
use std::process::Output;
|
||||
|
||||
use super::{ensure_author_config, git_command};
|
||||
|
||||
const DEFAULT_REMOTE_NAME: &str = "origin";
|
||||
|
||||
@@ -90,6 +92,8 @@ pub fn git_add_remote(vault_path: &str, remote_url: &str) -> Result<GitAddRemote
|
||||
));
|
||||
}
|
||||
|
||||
ensure_author_config(vault)?;
|
||||
|
||||
let branch = current_branch(vault)?;
|
||||
if branch.is_empty() {
|
||||
return Ok(connect_result(
|
||||
@@ -192,14 +196,14 @@ fn list_remotes(vault: &Path) -> Result<Vec<String>, String> {
|
||||
}
|
||||
|
||||
fn unset_upstream(vault: &Path) {
|
||||
let _ = Command::new("git")
|
||||
let _ = git_command()
|
||||
.args(["branch", "--unset-upstream"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
}
|
||||
|
||||
fn run_git(vault: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(args)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -237,7 +241,7 @@ fn list_remote_branches(vault: &Path) -> Result<Vec<String>, String> {
|
||||
}
|
||||
|
||||
fn histories_share_base(vault: &Path, connection: &RemoteConnection) -> bool {
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["merge-base", "HEAD", connection.remote_branch.as_str()])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -315,7 +319,7 @@ fn classify_connect_error(stderr: &str) -> GitAddRemoteResult {
|
||||
}
|
||||
|
||||
fn git_output(vault: &Path, args: &[&str]) -> Result<Output, String> {
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(args)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -442,6 +446,28 @@ mod tests {
|
||||
git_commit(path.to_str().unwrap(), message).unwrap();
|
||||
}
|
||||
|
||||
fn clear_local_author(path: &Path) {
|
||||
for key in ["user.name", "user.email"] {
|
||||
StdCommand::new("git")
|
||||
.args(["config", "--local", "--unset-all", key])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn local_author_is_configured(path: &Path) -> bool {
|
||||
["user.name", "user.email"].into_iter().all(|key| {
|
||||
let output = StdCommand::new("git")
|
||||
.args(["config", "--local", key])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
output.status.success() && !String::from_utf8_lossy(&output.stdout).trim().is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_all_remotes_removes_every_remote() {
|
||||
let dir = setup_git_repo();
|
||||
@@ -487,6 +513,26 @@ mod tests {
|
||||
assert_eq!((status.ahead, status.behind), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_add_remote_sets_local_identity_when_existing_repo_has_none() {
|
||||
let local = setup_git_repo();
|
||||
create_local_commit(local.path(), "note.md", "Local", "Initial local commit");
|
||||
clear_local_author(local.path());
|
||||
assert!(!local_author_is_configured(local.path()));
|
||||
|
||||
let bare = TempDir::new().unwrap();
|
||||
init_bare_remote(bare.path());
|
||||
|
||||
let result = git_add_remote(
|
||||
local.path().to_str().unwrap(),
|
||||
bare.path().to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.status, "connected");
|
||||
assert!(local_author_is_configured(local.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_add_remote_pushes_when_remote_is_the_local_branch_ancestor() {
|
||||
let local = setup_git_repo();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::git_command;
|
||||
use chrono::DateTime;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Git-derived creation and modification timestamps for a file.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -19,7 +19,7 @@ pub struct GitDates {
|
||||
/// Files not yet committed (untracked / only staged) will not appear in the map;
|
||||
/// callers should fall back to filesystem metadata for those.
|
||||
pub fn get_all_file_dates(vault_path: &Path) -> HashMap<String, GitDates> {
|
||||
let output = match Command::new("git")
|
||||
let output = match git_command()
|
||||
.args(["log", "--format=COMMIT %aI", "--name-only"])
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::git_command;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::GitCommit;
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitComm
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args([
|
||||
"log",
|
||||
"--format=%H|%h|%an|%aI|%s",
|
||||
@@ -80,7 +80,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
|
||||
// First try tracked file diff
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["diff", "--", relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -90,7 +90,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
|
||||
|
||||
// If no diff (maybe staged or untracked), try diff --cached
|
||||
if stdout.is_empty() {
|
||||
let cached = Command::new("git")
|
||||
let cached = git_command()
|
||||
.args(["diff", "--cached", "--", relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -102,7 +102,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
|
||||
}
|
||||
|
||||
// Try showing untracked file as all-new
|
||||
let status = Command::new("git")
|
||||
let status = git_command()
|
||||
.args(["status", "--porcelain", "--", relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -144,7 +144,7 @@ pub fn get_file_diff_at_commit(
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
|
||||
// Show diff between commit^ and commit for this file
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args([
|
||||
"diff",
|
||||
&format!("{}^", commit_hash),
|
||||
@@ -161,7 +161,7 @@ pub fn get_file_diff_at_commit(
|
||||
// If diff is empty, it might be the initial commit (no parent).
|
||||
// Fall back to showing the full file content as added.
|
||||
if stdout.is_empty() {
|
||||
let show = Command::new("git")
|
||||
let show = git_command()
|
||||
.args(["show", &format!("{}:{}", commit_hash, relative_str)])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -184,10 +184,10 @@ pub fn get_file_diff_at_commit(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::git_command;
|
||||
use super::*;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_get_file_history_with_commits() {
|
||||
@@ -196,24 +196,24 @@ mod tests {
|
||||
let file = vault.join("test.md");
|
||||
|
||||
fs::write(&file, "# Initial\n").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
fs::write(&file, "# Updated\n\nNew content.").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "Update test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -248,12 +248,12 @@ mod tests {
|
||||
let file = vault.join("diff-test.md");
|
||||
|
||||
fs::write(&file, "# Test\n\nOriginal content.").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "diff-test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "Add diff-test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -275,31 +275,31 @@ mod tests {
|
||||
let file = vault.join("diff-at-commit.md");
|
||||
|
||||
fs::write(&file, "# First\n\nOriginal content.").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "diff-at-commit.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "First commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
fs::write(&file, "# First\n\nModified content.").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "diff-at-commit.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "Second commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Get hash of second commit
|
||||
let log = Command::new("git")
|
||||
let log = git_command()
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -321,18 +321,18 @@ mod tests {
|
||||
let file = vault.join("initial.md");
|
||||
|
||||
fs::write(&file, "# Initial\n\nHello world.").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "initial.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let log = Command::new("git")
|
||||
let log = git_command()
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
|
||||
@@ -56,6 +56,10 @@ const DEFAULT_GITIGNORE: &str = "# Tolaria app files (machine-specific, never co
|
||||
*.swp\n\
|
||||
*.swo\n";
|
||||
|
||||
fn git_command() -> Command {
|
||||
crate::hidden_command("git")
|
||||
}
|
||||
|
||||
/// Ensure a `.gitignore` with sensible defaults exists in the vault directory.
|
||||
/// Creates the file if missing; leaves existing `.gitignore` files untouched.
|
||||
pub fn ensure_gitignore(path: &str) -> Result<(), String> {
|
||||
@@ -99,7 +103,7 @@ fn commit_initial_vault_setup(dir: &Path) -> Result<(), String> {
|
||||
|
||||
/// Run a git command in the given directory, returning an error on failure.
|
||||
fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
@@ -125,21 +129,20 @@ fn git_command_label<'a>(args: &'a [&'a str]) -> &'a str {
|
||||
}
|
||||
|
||||
/// Set local user.name and user.email if not already configured.
|
||||
fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
for (key, fallback) in [
|
||||
("user.name", "Tolaria"),
|
||||
("user.email", "vault@tolaria.app"),
|
||||
] {
|
||||
let check = Command::new("git")
|
||||
.args(["config", key])
|
||||
pub(crate) fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
for (key, fallback) in [("user.name", "Tolaria"), ("user.email", "vault@tolaria.md")] {
|
||||
let local = git_command()
|
||||
.args(["config", "--local", key])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check git config: {}", e))?;
|
||||
.map_err(|e| format!("Failed to check git config {key}: {e}"))?;
|
||||
|
||||
let value = String::from_utf8_lossy(&check.stdout);
|
||||
if !check.status.success() || value.trim().is_empty() {
|
||||
run_git(dir, &["config", key, fallback])?;
|
||||
let value = String::from_utf8_lossy(&local.stdout);
|
||||
if local.status.success() && !value.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
run_git(dir, &["config", "--local", key, fallback])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -174,7 +177,6 @@ fn parse_github_repo_path(url: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn assert_repo_path(url: &str, expected: Option<&str>) {
|
||||
@@ -188,19 +190,19 @@ mod tests {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["init", "--initial-branch=main"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["config", "user.name", "Test User"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
@@ -214,14 +216,14 @@ mod tests {
|
||||
let bare_dir = TempDir::new().unwrap();
|
||||
let bare = bare_dir.path();
|
||||
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["init", "--bare"])
|
||||
.current_dir(bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let clone_a_dir = TempDir::new().unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["clone", bare.to_str().unwrap(), "."])
|
||||
.current_dir(clone_a_dir.path())
|
||||
.output()
|
||||
@@ -230,7 +232,7 @@ mod tests {
|
||||
&["config", "user.email", "a@test.com"][..],
|
||||
&["config", "user.name", "User A"][..],
|
||||
] {
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(*cmd)
|
||||
.current_dir(clone_a_dir.path())
|
||||
.output()
|
||||
@@ -238,7 +240,7 @@ mod tests {
|
||||
}
|
||||
|
||||
let clone_b_dir = TempDir::new().unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["clone", bare.to_str().unwrap(), "."])
|
||||
.current_dir(clone_b_dir.path())
|
||||
.output()
|
||||
@@ -247,7 +249,7 @@ mod tests {
|
||||
&["config", "user.email", "b@test.com"][..],
|
||||
&["config", "user.name", "User B"][..],
|
||||
] {
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(*cmd)
|
||||
.current_dir(clone_b_dir.path())
|
||||
.output()
|
||||
@@ -301,7 +303,7 @@ mod tests {
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let log = Command::new("git")
|
||||
let log = git_command()
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
@@ -317,17 +319,17 @@ mod tests {
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Test\n").unwrap();
|
||||
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["init"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["config", "commit.gpgsign", "true"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["config", "gpg.program", "/missing/tolaria-test-gpg"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
@@ -335,7 +337,7 @@ mod tests {
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let log = Command::new("git")
|
||||
let log = git_command()
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
@@ -353,7 +355,7 @@ mod tests {
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let status = Command::new("git")
|
||||
let status = git_command()
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::parse_github_repo_path;
|
||||
use super::{git_command, parse_github_repo_path};
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct PulseFile {
|
||||
@@ -67,7 +66,7 @@ pub fn get_vault_pulse(
|
||||
|
||||
let limit_str = limit.to_string();
|
||||
let skip_str = skip.to_string();
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args([
|
||||
"log",
|
||||
"--name-status",
|
||||
@@ -99,7 +98,7 @@ pub fn get_vault_pulse(
|
||||
|
||||
fn get_github_base_url(vault_path: &str) -> Option<String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -123,69 +122,90 @@ fn parse_pulse_output(stdout: &str, github_base: &Option<String>) -> Vec<PulseCo
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.contains('|')
|
||||
&& !line.starts_with(|c: char| {
|
||||
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
|
||||
})
|
||||
{
|
||||
// Commit header line: hash|short_hash|message|date
|
||||
if let Some(commit) = current.take() {
|
||||
commits.push(commit);
|
||||
}
|
||||
let parts: Vec<&str> = line.splitn(4, '|').collect();
|
||||
if parts.len() == 4 {
|
||||
let hash = parts[0];
|
||||
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0);
|
||||
let github_url = github_base
|
||||
.as_ref()
|
||||
.map(|base| format!("{}/commit/{}", base, hash));
|
||||
if is_commit_header(line) {
|
||||
push_current_commit(&mut commits, &mut current);
|
||||
current = parse_commit_header(line, github_base);
|
||||
continue;
|
||||
}
|
||||
|
||||
current = Some(PulseCommit {
|
||||
hash: hash.to_string(),
|
||||
short_hash: parts[1].to_string(),
|
||||
message: parts[2].to_string(),
|
||||
date,
|
||||
github_url,
|
||||
files: Vec::new(),
|
||||
added: 0,
|
||||
modified: 0,
|
||||
deleted: 0,
|
||||
});
|
||||
}
|
||||
} else if let Some(ref mut commit) = current {
|
||||
// File status line: A\tpath or M\tpath
|
||||
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
|
||||
if file_parts.len() == 2 {
|
||||
let status = parse_file_status(file_parts[0].trim());
|
||||
let path = file_parts[1].trim();
|
||||
match status {
|
||||
"added" => commit.added += 1,
|
||||
"deleted" => commit.deleted += 1,
|
||||
_ => commit.modified += 1,
|
||||
}
|
||||
commit.files.push(PulseFile {
|
||||
path: path.to_string(),
|
||||
status: status.to_string(),
|
||||
title: title_from_path(path),
|
||||
});
|
||||
}
|
||||
if let Some(ref mut commit) = current {
|
||||
add_file_change(commit, line);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(commit) = current {
|
||||
commits.push(commit);
|
||||
}
|
||||
push_current_commit(&mut commits, &mut current);
|
||||
|
||||
commits
|
||||
}
|
||||
|
||||
fn is_git_status_line(line: &str) -> bool {
|
||||
line.starts_with(|c: char| {
|
||||
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
|
||||
})
|
||||
}
|
||||
|
||||
fn is_commit_header(line: &str) -> bool {
|
||||
line.contains('|') && !is_git_status_line(line)
|
||||
}
|
||||
|
||||
fn push_current_commit(commits: &mut Vec<PulseCommit>, current: &mut Option<PulseCommit>) {
|
||||
if let Some(commit) = current.take() {
|
||||
commits.push(commit);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_commit_header(line: &str, github_base: &Option<String>) -> Option<PulseCommit> {
|
||||
let parts: Vec<&str> = line.splitn(4, '|').collect();
|
||||
if parts.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hash = parts[0];
|
||||
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0);
|
||||
let github_url = github_base
|
||||
.as_ref()
|
||||
.map(|base| format!("{}/commit/{}", base, hash));
|
||||
|
||||
Some(PulseCommit {
|
||||
hash: hash.to_string(),
|
||||
short_hash: parts[1].to_string(),
|
||||
message: parts[2].to_string(),
|
||||
date,
|
||||
github_url,
|
||||
files: Vec::new(),
|
||||
added: 0,
|
||||
modified: 0,
|
||||
deleted: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn add_file_change(commit: &mut PulseCommit, line: &str) {
|
||||
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
|
||||
if file_parts.len() != 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let status = parse_file_status(file_parts[0].trim());
|
||||
let path = file_parts[1].trim();
|
||||
match status {
|
||||
"added" => commit.added += 1,
|
||||
"deleted" => commit.deleted += 1,
|
||||
_ => commit.modified += 1,
|
||||
}
|
||||
commit.files.push(PulseFile {
|
||||
path: path.to_string(),
|
||||
status: status.to_string(),
|
||||
title: title_from_path(path),
|
||||
});
|
||||
}
|
||||
|
||||
/// Get the last commit's short hash and a GitHub URL (if remote is GitHub).
|
||||
pub fn get_last_commit_info(vault_path: &str) -> Result<Option<LastCommitInfo>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["log", "-1", "--format=%H|%h"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -223,23 +243,7 @@ pub fn get_last_commit_info(vault_path: &str) -> Result<Option<LastCommitInfo>,
|
||||
|
||||
/// Try to build a GitHub commit URL from the origin remote URL.
|
||||
fn get_github_commit_url(vault_path: &str, full_hash: &str) -> Option<String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let repo_path = parse_github_repo_path(&url)?;
|
||||
Some(format!(
|
||||
"https://github.com/{}/commit/{}",
|
||||
repo_path, full_hash
|
||||
))
|
||||
get_github_base_url(vault_path).map(|base| format!("{}/commit/{}", base, full_hash))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::git_command;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::conflict::get_conflict_files;
|
||||
|
||||
@@ -17,7 +17,7 @@ pub struct GitPullResult {
|
||||
/// Check whether the vault repo has at least one remote configured.
|
||||
pub fn has_remote(vault_path: &str) -> Result<bool, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["remote"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -40,7 +40,7 @@ pub fn git_pull(vault_path: &str) -> Result<GitPullResult, String> {
|
||||
});
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["pull", "--no-rebase"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -134,14 +134,14 @@ pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
|
||||
}
|
||||
|
||||
// Fetch latest remote refs (silent, best-effort)
|
||||
let _ = Command::new("git")
|
||||
let _ = git_command()
|
||||
.args(["fetch", "--quiet"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
let branch = current_branch(vault)?;
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -171,7 +171,7 @@ pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
|
||||
}
|
||||
|
||||
fn current_branch(vault: &Path) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["branch", "--show-current"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -189,59 +189,93 @@ pub struct GitPushResult {
|
||||
pub fn classify_push_error(stderr: &str) -> GitPushResult {
|
||||
let lower = stderr.to_lowercase();
|
||||
|
||||
if lower.contains("non-fast-forward")
|
||||
|| lower.contains("[rejected]")
|
||||
|| lower.contains("fetch first")
|
||||
|| lower.contains("failed to push some refs")
|
||||
&& (lower.contains("updates were rejected") || lower.contains("non-fast-forward"))
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "rejected".to_string(),
|
||||
message: "Push rejected: remote has new commits. Pull first, then push.".to_string(),
|
||||
};
|
||||
if is_rejected_push_error(&lower) {
|
||||
return push_error(
|
||||
"rejected",
|
||||
"Push rejected: remote has new commits. Pull first, then push.",
|
||||
);
|
||||
}
|
||||
|
||||
if lower.contains("authentication failed")
|
||||
|| lower.contains("could not read username")
|
||||
|| lower.contains("permission denied")
|
||||
|| lower.contains("403")
|
||||
|| lower.contains("invalid credentials")
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "auth_error".to_string(),
|
||||
message: "Push failed: authentication error. Check your credentials.".to_string(),
|
||||
};
|
||||
if is_auth_push_error(&lower) {
|
||||
return push_error(
|
||||
"auth_error",
|
||||
"Push failed: authentication error. Check your credentials.",
|
||||
);
|
||||
}
|
||||
|
||||
if lower.contains("could not resolve host")
|
||||
|| lower.contains("unable to access")
|
||||
|| lower.contains("connection refused")
|
||||
|| lower.contains("network is unreachable")
|
||||
|| lower.contains("timed out")
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "network_error".to_string(),
|
||||
message: "Push failed: network error. Check your connection and try again.".to_string(),
|
||||
};
|
||||
if is_network_push_error(&lower) {
|
||||
return push_error(
|
||||
"network_error",
|
||||
"Push failed: network error. Check your connection and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: extract the hint line if present, otherwise use the full stderr
|
||||
push_error(
|
||||
"error",
|
||||
format!("Push failed: {}", push_error_detail(stderr)),
|
||||
)
|
||||
}
|
||||
|
||||
fn push_error(status: &str, message: impl Into<String>) -> GitPushResult {
|
||||
GitPushResult {
|
||||
status: status.to_string(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_any(haystack: &str, needles: &[&str]) -> bool {
|
||||
needles.iter().any(|needle| haystack.contains(needle))
|
||||
}
|
||||
|
||||
fn is_rejected_push_error(lower: &str) -> bool {
|
||||
contains_any(lower, &["non-fast-forward", "[rejected]", "fetch first"])
|
||||
|| (lower.contains("failed to push some refs")
|
||||
&& contains_any(lower, &["updates were rejected", "non-fast-forward"]))
|
||||
}
|
||||
|
||||
fn is_auth_push_error(lower: &str) -> bool {
|
||||
contains_any(
|
||||
lower,
|
||||
&[
|
||||
"authentication failed",
|
||||
"could not read username",
|
||||
"permission denied",
|
||||
"403",
|
||||
"invalid credentials",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn is_network_push_error(lower: &str) -> bool {
|
||||
contains_any(
|
||||
lower,
|
||||
&[
|
||||
"could not resolve host",
|
||||
"unable to access",
|
||||
"connection refused",
|
||||
"network is unreachable",
|
||||
"timed out",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn push_error_detail(stderr: &str) -> String {
|
||||
let hint_line = stderr
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("hint:"))
|
||||
.map(|l| l.trim_start().strip_prefix("hint:").unwrap_or(l).trim())
|
||||
.find(|line| line.trim_start().starts_with("hint:"))
|
||||
.map(|line| {
|
||||
line.trim_start()
|
||||
.strip_prefix("hint:")
|
||||
.unwrap_or(line)
|
||||
.trim()
|
||||
})
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let detail = if hint_line.is_empty() {
|
||||
if hint_line.is_empty() {
|
||||
stderr.trim().to_string()
|
||||
} else {
|
||||
hint_line
|
||||
};
|
||||
|
||||
GitPushResult {
|
||||
status: "error".to_string(),
|
||||
message: format!("Push failed: {detail}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +283,7 @@ pub fn classify_push_error(stderr: &str) -> GitPushResult {
|
||||
pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["push"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -272,7 +306,6 @@ mod tests {
|
||||
use crate::git::git_commit;
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_has_remote_returns_false_for_local_repo() {
|
||||
@@ -289,7 +322,7 @@ mod tests {
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["remote", "add", "origin", "https://example.com/repo.git"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::git_command;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ModifiedFile {
|
||||
@@ -23,14 +23,34 @@ struct DiffStats {
|
||||
binary: bool,
|
||||
}
|
||||
|
||||
fn status_label(status_code: &str) -> &'static str {
|
||||
match status_code.trim() {
|
||||
"M" | "MM" | "AM" => "modified",
|
||||
"A" => "added",
|
||||
"D" => "deleted",
|
||||
"??" => "untracked",
|
||||
"R" | "RM" => "renamed",
|
||||
_ => "modified",
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FileChangeStatus {
|
||||
Modified,
|
||||
Added,
|
||||
Deleted,
|
||||
Untracked,
|
||||
Renamed,
|
||||
}
|
||||
|
||||
impl FileChangeStatus {
|
||||
fn from_code(status_code: &str) -> Self {
|
||||
match status_code.trim() {
|
||||
"A" => Self::Added,
|
||||
"D" => Self::Deleted,
|
||||
"??" => Self::Untracked,
|
||||
"R" | "RM" => Self::Renamed,
|
||||
_ => Self::Modified,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Added => "added",
|
||||
Self::Deleted => "deleted",
|
||||
Self::Untracked => "untracked",
|
||||
Self::Renamed => "renamed",
|
||||
Self::Modified => "modified",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +88,7 @@ fn parse_numstat_line(line: &str) -> Option<(String, DiffStats)> {
|
||||
}
|
||||
|
||||
fn repo_has_head(vault: &Path) -> Result<bool, String> {
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["rev-parse", "--verify", "HEAD"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -82,7 +102,7 @@ fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["diff", "--numstat", "--find-renames", "HEAD", "--"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -100,7 +120,7 @@ fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
|
||||
.collect::<HashMap<_, _>>())
|
||||
}
|
||||
|
||||
fn count_worktree_lines(vault: &Path, relative_path: &str) -> DiffStats {
|
||||
fn count_worktree_lines(vault: &Path, relative_path: &Path) -> DiffStats {
|
||||
let full_path = vault.join(relative_path);
|
||||
let added_lines = std::fs::read_to_string(full_path)
|
||||
.ok()
|
||||
@@ -115,19 +135,20 @@ fn count_worktree_lines(vault: &Path, relative_path: &str) -> DiffStats {
|
||||
|
||||
fn resolve_diff_stats(
|
||||
vault: &Path,
|
||||
relative_path: &str,
|
||||
status: &str,
|
||||
relative_path: &Path,
|
||||
status: FileChangeStatus,
|
||||
diff_stats: &HashMap<String, DiffStats>,
|
||||
) -> DiffStats {
|
||||
if status == "untracked" {
|
||||
if status == FileChangeStatus::Untracked {
|
||||
return count_worktree_lines(vault, relative_path);
|
||||
}
|
||||
|
||||
diff_stats.get(relative_path).copied().unwrap_or_default()
|
||||
let key = relative_path.to_string_lossy();
|
||||
diff_stats.get(key.as_ref()).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn ensure_path_within_vault(vault: &Path, relative_path: &str, abs: &Path) -> Result<(), String> {
|
||||
for component in Path::new(relative_path).components() {
|
||||
fn ensure_path_within_vault(vault: &Path, relative_path: &Path, abs: &Path) -> Result<(), String> {
|
||||
for component in relative_path.components() {
|
||||
if matches!(component, std::path::Component::ParentDir) {
|
||||
return Err("File path is outside the vault".into());
|
||||
}
|
||||
@@ -151,9 +172,10 @@ fn ensure_path_within_vault(vault: &Path, relative_path: &str, abs: &Path) -> Re
|
||||
}
|
||||
}
|
||||
|
||||
fn load_file_status(vault: &Path, relative_path: &str) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain", "--", relative_path])
|
||||
fn load_file_status(vault: &Path, relative_path: &Path) -> Result<String, String> {
|
||||
let output = git_command()
|
||||
.args(["status", "--porcelain", "--"])
|
||||
.arg(relative_path)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {e}"))?;
|
||||
@@ -166,14 +188,16 @@ fn load_file_status(vault: &Path, relative_path: &str) -> Result<String, String>
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn restore_tracked_file(vault: &Path, relative_path: &str) -> Result<(), String> {
|
||||
let _ = Command::new("git")
|
||||
.args(["reset", "HEAD", "--", relative_path])
|
||||
fn restore_tracked_file(vault: &Path, relative_path: &Path) -> Result<(), String> {
|
||||
let _ = git_command()
|
||||
.args(["reset", "HEAD", "--"])
|
||||
.arg(relative_path)
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
let checkout = Command::new("git")
|
||||
.args(["checkout", "--", relative_path])
|
||||
let checkout = git_command()
|
||||
.args(["checkout", "--"])
|
||||
.arg(relative_path)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git checkout: {e}"))?;
|
||||
@@ -189,7 +213,7 @@ fn restore_tracked_file(vault: &Path, relative_path: &str) -> Result<(), String>
|
||||
/// Get list of modified/added/deleted files in the vault (uncommitted changes).
|
||||
pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["status", "--porcelain", "--untracked-files=all"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -217,14 +241,14 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = status_label(status_code);
|
||||
let status = FileChangeStatus::from_code(status_code);
|
||||
let full_path = vault.join(&relative_path).to_string_lossy().to_string();
|
||||
let stats = resolve_diff_stats(vault, &relative_path, status, &diff_stats);
|
||||
let stats = resolve_diff_stats(vault, Path::new(&relative_path), status, &diff_stats);
|
||||
|
||||
Some(ModifiedFile {
|
||||
path: full_path,
|
||||
relative_path,
|
||||
status: status.to_string(),
|
||||
status: status.label().to_string(),
|
||||
added_lines: stats.added_lines,
|
||||
deleted_lines: stats.deleted_lines,
|
||||
binary: stats.binary,
|
||||
@@ -244,10 +268,11 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
|
||||
/// returned by [`get_modified_files`]).
|
||||
pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(), String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let abs = vault.join(relative_path);
|
||||
let relative = Path::new(relative_path);
|
||||
let abs = vault.join(relative);
|
||||
|
||||
ensure_path_within_vault(vault, relative_path, &abs)?;
|
||||
let status_code = load_file_status(vault, relative_path)?;
|
||||
ensure_path_within_vault(vault, relative, &abs)?;
|
||||
let status_code = load_file_status(vault, relative)?;
|
||||
|
||||
match status_code.as_str() {
|
||||
"??" => {
|
||||
@@ -255,7 +280,7 @@ pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(),
|
||||
.map_err(|e| format!("Failed to delete untracked file: {e}"))?;
|
||||
}
|
||||
_ => {
|
||||
restore_tracked_file(vault, relative_path)?;
|
||||
restore_tracked_file(vault, relative)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,11 +289,11 @@ pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(),
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::git_command;
|
||||
use super::*;
|
||||
use crate::git::git_commit;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
fn write_and_commit_markdown(vault: &Path, vp: &str, relative_path: &str, content: &str) {
|
||||
fs::write(vault.join(relative_path), content).unwrap();
|
||||
@@ -282,12 +307,12 @@ mod tests {
|
||||
|
||||
// Create and commit a file
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "note.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "Add note"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -327,12 +352,12 @@ mod tests {
|
||||
|
||||
// Create initial commit so git is initialized
|
||||
fs::write(vault.join("init.md"), "# Init\n").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "init.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "Initial"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
pub mod ai_agents;
|
||||
pub mod app_updater;
|
||||
pub mod claude_cli;
|
||||
mod cli_agent_runtime;
|
||||
pub mod codex_cli;
|
||||
mod commands;
|
||||
pub mod frontmatter;
|
||||
pub mod gemini_cli;
|
||||
mod gemini_config;
|
||||
mod gemini_discovery;
|
||||
pub mod git;
|
||||
pub mod mcp;
|
||||
#[cfg(desktop)]
|
||||
pub mod menu;
|
||||
pub mod opencode_cli;
|
||||
mod opencode_config;
|
||||
mod opencode_discovery;
|
||||
mod opencode_events;
|
||||
pub mod pi_cli;
|
||||
mod pi_config;
|
||||
mod pi_discovery;
|
||||
mod pi_events;
|
||||
pub mod search;
|
||||
pub mod settings;
|
||||
pub mod telemetry;
|
||||
pub mod vault;
|
||||
pub mod vault_list;
|
||||
pub mod vault_watcher;
|
||||
#[cfg(desktop)]
|
||||
mod window_state;
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
use std::os::unix::process::CommandExt;
|
||||
#[cfg(desktop)]
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(desktop)]
|
||||
@@ -20,11 +41,149 @@ use std::process::Child;
|
||||
#[cfg(desktop)]
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
pub(crate) fn hidden_command(program: impl AsRef<OsStr>) -> Command {
|
||||
let mut command = Command::new(program);
|
||||
suppress_windows_console(&mut command);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn suppress_windows_console(command: &mut Command) {
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn suppress_windows_console(_command: &mut Command) {}
|
||||
|
||||
#[cfg(desktop)]
|
||||
struct WsBridgeChild(Mutex<Option<Child>>);
|
||||
|
||||
#[cfg(desktop)]
|
||||
struct ActiveAssetScopeRoots(Mutex<Vec<PathBuf>>);
|
||||
struct AllowedAssetScopeRoots(Mutex<Vec<PathBuf>>);
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct StartupEnvOverride {
|
||||
key: &'static str,
|
||||
value: &'static str,
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
const WAYLAND_CLIENT_PRELOAD_CANDIDATES: [&str; 7] = [
|
||||
"/usr/lib/libwayland-client.so",
|
||||
"/usr/lib/libwayland-client.so.0",
|
||||
"/usr/lib64/libwayland-client.so",
|
||||
"/usr/lib64/libwayland-client.so.0",
|
||||
"/lib64/libwayland-client.so.0",
|
||||
"/lib/x86_64-linux-gnu/libwayland-client.so.0",
|
||||
"/usr/lib/x86_64-linux-gnu/libwayland-client.so.0",
|
||||
];
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
fn is_linux_appimage_launch<F>(mut get_var: F) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
["APPIMAGE", "APPDIR"]
|
||||
.into_iter()
|
||||
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()))
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
fn is_wayland_session<F>(mut get_var: F) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
get_var("WAYLAND_DISPLAY").is_some_and(|value| !value.trim().is_empty())
|
||||
|| get_var("XDG_SESSION_TYPE")
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case("wayland"))
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
fn linux_appimage_wayland_client_preload_path_with<F, E>(
|
||||
mut get_var: F,
|
||||
mut file_exists: E,
|
||||
) -> Option<&'static str>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
E: FnMut(&str) -> bool,
|
||||
{
|
||||
if !is_linux_appimage_launch(&mut get_var) || !is_wayland_session(&mut get_var) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if get_var("LD_PRELOAD").is_some_and(|value| !value.trim().is_empty())
|
||||
|| get_var("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED").is_some_and(|value| value == "1")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
WAYLAND_CLIENT_PRELOAD_CANDIDATES
|
||||
.into_iter()
|
||||
.find(|path| file_exists(path))
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
fn linux_appimage_startup_env_overrides_with<F>(mut get_var: F) -> Vec<StartupEnvOverride>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
if !is_linux_appimage_launch(&mut get_var) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if get_var("WEBKIT_DISABLE_DMABUF_RENDERER").is_some_and(|value| !value.trim().is_empty()) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![StartupEnvOverride {
|
||||
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
|
||||
value: "1",
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn apply_linux_appimage_startup_env_overrides() {
|
||||
apply_linux_appimage_wayland_client_preload();
|
||||
|
||||
for env_override in linux_appimage_startup_env_overrides_with(|key| std::env::var(key).ok()) {
|
||||
std::env::set_var(env_override.key, env_override.value);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn apply_linux_appimage_wayland_client_preload() {
|
||||
let Some(preload_path) = linux_appimage_wayland_client_preload_path_with(
|
||||
|key| std::env::var(key).ok(),
|
||||
|path| std::path::Path::new(path).is_file(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let exe = match std::env::current_exe() {
|
||||
Ok(exe) => exe,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Tolaria AppImage Wayland preload skipped: failed to resolve executable ({e})"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let error = Command::new(exe)
|
||||
.args(std::env::args_os().skip(1))
|
||||
.env("LD_PRELOAD", preload_path)
|
||||
.env("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED", "1")
|
||||
.exec();
|
||||
eprintln!("Tolaria AppImage Wayland preload skipped: failed to re-exec ({error})");
|
||||
}
|
||||
|
||||
#[cfg(not(all(desktop, target_os = "linux")))]
|
||||
fn apply_linux_appimage_startup_env_overrides() {}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
@@ -35,6 +194,78 @@ fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn selected_mcp_bridge_vault_path(vault_list: &vault_list::VaultList) -> Option<PathBuf> {
|
||||
vault_list
|
||||
.active_vault
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn validate_mcp_bridge_vault_path(vault_path: &Path) -> Result<PathBuf, String> {
|
||||
let resolved = std::fs::canonicalize(vault_path).map_err(|e| {
|
||||
format!(
|
||||
"MCP bridge vault is not available: {} ({e})",
|
||||
vault_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if !resolved.is_dir() {
|
||||
return Err(format!(
|
||||
"MCP bridge vault is not available: {} is not a directory",
|
||||
vault_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn stop_ws_bridge_child(active_child: &mut Option<Child>) {
|
||||
if let Some(mut child) = active_child.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
log::info!("ws-bridge child process stopped");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
pub(crate) fn sync_ws_bridge_for_vault(
|
||||
app_handle: &tauri::AppHandle,
|
||||
vault_path: Option<&Path>,
|
||||
) -> Result<&'static str, String> {
|
||||
use tauri::Manager;
|
||||
|
||||
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
|
||||
let mut active_child = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock ws-bridge state".to_string())?;
|
||||
|
||||
let Some(vault_path) = vault_path else {
|
||||
stop_ws_bridge_child(&mut active_child);
|
||||
return Ok("stopped");
|
||||
};
|
||||
|
||||
let resolved_vault_path = match validate_mcp_bridge_vault_path(vault_path) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
stop_ws_bridge_child(&mut active_child);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
stop_ws_bridge_child(&mut active_child);
|
||||
|
||||
let child = mcp::spawn_ws_bridge(&resolved_vault_path)?;
|
||||
|
||||
*active_child = Some(child);
|
||||
Ok("started")
|
||||
}
|
||||
|
||||
/// Run startup housekeeping on the default vault (migrate legacy frontmatter, seed configs).
|
||||
#[cfg(desktop)]
|
||||
fn run_startup_tasks() {
|
||||
@@ -57,17 +288,21 @@ fn run_startup_tasks() {
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn spawn_ws_bridge(app: &mut tauri::App) {
|
||||
use tauri::Manager;
|
||||
let vault_path = dirs::home_dir()
|
||||
.map(|h| h.join("Laputa"))
|
||||
.unwrap_or_default();
|
||||
let vp_str = vault_path.to_string_lossy().to_string();
|
||||
match mcp::spawn_ws_bridge(&vp_str) {
|
||||
Ok(child) => {
|
||||
let state: tauri::State<'_, WsBridgeChild> = app.state();
|
||||
*state.0.lock().unwrap() = Some(child);
|
||||
let vault_path = match vault_list::load_vault_list() {
|
||||
Ok(vault_list) => selected_mcp_bridge_vault_path(&vault_list),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load active vault for ws-bridge startup: {}", e);
|
||||
None
|
||||
}
|
||||
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
|
||||
};
|
||||
|
||||
let Some(vault_path) = vault_path else {
|
||||
log::info!("ws-bridge not started: no active vault selected");
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(e) = sync_ws_bridge_for_vault(app.handle(), Some(&vault_path)) {
|
||||
log::warn!("Failed to start ws-bridge: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,11 +326,31 @@ 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)?;
|
||||
window_state::restore_main_window_state(app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O"];
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn setup_linux_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use tauri::Manager;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_decorations(false);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(all(desktop, target_os = "linux")))]
|
||||
fn setup_linux_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "macos")))]
|
||||
const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O", "F"];
|
||||
#[cfg(any(test, all(desktop, target_os = "macos")))]
|
||||
const MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS: &[&str] = &["L"];
|
||||
|
||||
#[cfg(all(desktop, target_os = "macos"))]
|
||||
@@ -163,6 +418,18 @@ fn vault_asset_scope_roots(vault_path: &Path) -> Result<Vec<PathBuf>, String> {
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn missing_asset_scope_roots(
|
||||
allowed_roots: &[PathBuf],
|
||||
requested_roots: &[PathBuf],
|
||||
) -> Vec<PathBuf> {
|
||||
requested_roots
|
||||
.iter()
|
||||
.filter(|root| !allowed_roots.contains(root))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
pub(crate) fn sync_vault_asset_scope(
|
||||
app_handle: &tauri::AppHandle,
|
||||
@@ -170,27 +437,22 @@ pub(crate) fn sync_vault_asset_scope(
|
||||
) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
|
||||
let next_roots = vault_asset_scope_roots(vault_path)?;
|
||||
let requested_roots = vault_asset_scope_roots(vault_path)?;
|
||||
let scope = app_handle.asset_protocol_scope();
|
||||
let state: tauri::State<'_, ActiveAssetScopeRoots> = app_handle.state();
|
||||
let mut active_roots = state
|
||||
let state: tauri::State<'_, AllowedAssetScopeRoots> = app_handle.state();
|
||||
let mut allowed_roots = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock active asset scope state".to_string())?;
|
||||
.map_err(|_| "Failed to lock asset scope state".to_string())?;
|
||||
let roots_to_allow = missing_asset_scope_roots(&allowed_roots, &requested_roots);
|
||||
|
||||
for root in &next_roots {
|
||||
for root in &roots_to_allow {
|
||||
scope
|
||||
.allow_directory(root, true)
|
||||
.map_err(|e| format!("Failed to allow asset access for {}: {e}", root.display()))?;
|
||||
}
|
||||
|
||||
for previous_root in active_roots.iter() {
|
||||
if !next_roots.contains(previous_root) {
|
||||
let _ = scope.forbid_directory(previous_root, true);
|
||||
}
|
||||
}
|
||||
|
||||
*active_roots = next_roots;
|
||||
allowed_roots.extend(roots_to_allow);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -200,6 +462,7 @@ macro_rules! app_invoke_handler {
|
||||
commands::list_vault,
|
||||
commands::list_vault_folders,
|
||||
commands::get_note_content,
|
||||
commands::validate_note_content,
|
||||
commands::create_note_content,
|
||||
commands::save_note_content,
|
||||
commands::update_frontmatter,
|
||||
@@ -234,10 +497,11 @@ macro_rules! app_invoke_handler {
|
||||
commands::get_vault_ai_guidance_status,
|
||||
commands::restore_vault_ai_guidance,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
commands::stream_ai_agent,
|
||||
commands::reload_vault,
|
||||
commands::reload_vault_entry,
|
||||
commands::sync_vault_asset_scope_for_window,
|
||||
commands::open_vault_file_external,
|
||||
commands::sync_note_title,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
@@ -254,11 +518,12 @@ macro_rules! app_invoke_handler {
|
||||
commands::update_menu_state,
|
||||
commands::trigger_menu_command,
|
||||
commands::update_current_window_min_size,
|
||||
commands::perform_current_window_titlebar_double_click,
|
||||
commands::save_settings,
|
||||
commands::download_and_install_app_update,
|
||||
commands::load_vault_list,
|
||||
commands::save_vault_list,
|
||||
commands::clone_repo,
|
||||
commands::git_clone::clone_git_repo,
|
||||
commands::search_vault,
|
||||
commands::create_empty_vault,
|
||||
commands::create_getting_started_vault,
|
||||
@@ -267,11 +532,15 @@ macro_rules! app_invoke_handler {
|
||||
commands::register_mcp_tools,
|
||||
commands::remove_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::get_mcp_config_snippet,
|
||||
commands::sync_mcp_bridge_vault,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry,
|
||||
commands::list_views,
|
||||
commands::save_view_cmd,
|
||||
commands::delete_view_cmd
|
||||
commands::delete_view_cmd,
|
||||
vault_watcher::start_vault_watcher,
|
||||
vault_watcher::stop_vault_watcher
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -284,24 +553,26 @@ fn with_invoke_handler(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<ta
|
||||
fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
|
||||
use tauri::Manager;
|
||||
|
||||
window_state::handle_run_event(app_handle, event);
|
||||
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.kill();
|
||||
log::info!("ws-bridge child process killed on exit");
|
||||
}
|
||||
stop_ws_bridge_child(&mut guard);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
apply_linux_appimage_startup_env_overrides();
|
||||
let builder = tauri::Builder::default();
|
||||
|
||||
#[cfg(desktop)]
|
||||
let builder = builder
|
||||
.manage(WsBridgeChild(Mutex::new(None)))
|
||||
.manage(ActiveAssetScopeRoots(Mutex::new(Vec::new())));
|
||||
.manage(AllowedAssetScopeRoots(Mutex::new(Vec::new())))
|
||||
.manage(window_state::MainWindowFrameState::default())
|
||||
.manage(vault_watcher::VaultWatcherState::new());
|
||||
|
||||
with_invoke_handler(builder)
|
||||
.setup(setup_app)
|
||||
@@ -315,16 +586,152 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::linux_appimage_startup_env_overrides_with;
|
||||
use super::linux_appimage_wayland_client_preload_path_with;
|
||||
use super::StartupEnvOverride;
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use super::{
|
||||
missing_asset_scope_roots, selected_mcp_bridge_vault_path, validate_mcp_bridge_vault_path,
|
||||
};
|
||||
#[cfg(desktop)]
|
||||
use crate::vault_list::VaultList;
|
||||
#[cfg(desktop)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(all(desktop, unix))]
|
||||
use super::vault_asset_scope_roots;
|
||||
|
||||
#[test]
|
||||
fn macos_webview_shortcut_prevention_includes_ai_panel_shortcut() {
|
||||
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_KEYS, ["O", "F"]);
|
||||
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_appimage_startup_env_overrides_are_empty_outside_appimage_launches() {
|
||||
let overrides = linux_appimage_startup_env_overrides_with(|_| None);
|
||||
|
||||
assert!(overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_appimage_startup_env_overrides_disable_dmabuf_for_appimages() {
|
||||
let overrides = linux_appimage_startup_env_overrides_with(|key| match key {
|
||||
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
overrides,
|
||||
vec![StartupEnvOverride {
|
||||
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
|
||||
value: "1",
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_appimage_startup_env_overrides_preserve_explicit_user_setting() {
|
||||
let overrides = linux_appimage_startup_env_overrides_with(|key| match key {
|
||||
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
|
||||
"WEBKIT_DISABLE_DMABUF_RENDERER" => Some("0".to_string()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
assert!(overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_appimage_wayland_preload_uses_first_available_system_library() {
|
||||
let preload_path = linux_appimage_wayland_client_preload_path_with(
|
||||
|key| match key {
|
||||
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
|
||||
"XDG_SESSION_TYPE" => Some("wayland".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
|path| path == "/lib/x86_64-linux-gnu/libwayland-client.so.0",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
preload_path,
|
||||
Some("/lib/x86_64-linux-gnu/libwayland-client.so.0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_appimage_wayland_preload_preserves_explicit_ld_preload() {
|
||||
let preload_path = linux_appimage_wayland_client_preload_path_with(
|
||||
|key| match key {
|
||||
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
|
||||
"WAYLAND_DISPLAY" => Some("wayland-0".to_string()),
|
||||
"LD_PRELOAD" => Some("/custom/libwayland-client.so".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
|_| true,
|
||||
);
|
||||
|
||||
assert_eq!(preload_path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_appimage_wayland_preload_is_empty_for_x11_sessions() {
|
||||
let preload_path = linux_appimage_wayland_client_preload_path_with(
|
||||
|key| match key {
|
||||
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
|
||||
"XDG_SESSION_TYPE" => Some("x11".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
|_| true,
|
||||
);
|
||||
|
||||
assert_eq!(preload_path, None);
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn selected_mcp_bridge_vault_path_uses_persisted_active_vault() {
|
||||
let list = VaultList {
|
||||
vaults: Vec::new(),
|
||||
active_vault: Some("/tmp/Selected Vault".to_string()),
|
||||
hidden_defaults: Vec::new(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
selected_mcp_bridge_vault_path(&list),
|
||||
Some(std::path::PathBuf::from("/tmp/Selected Vault"))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn selected_mcp_bridge_vault_path_ignores_blank_active_vault() {
|
||||
let list = VaultList {
|
||||
vaults: Vec::new(),
|
||||
active_vault: Some(" ".to_string()),
|
||||
hidden_defaults: Vec::new(),
|
||||
};
|
||||
|
||||
assert_eq!(selected_mcp_bridge_vault_path(&list), None);
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn validate_mcp_bridge_vault_path_requires_existing_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().join("Vault With Spaces");
|
||||
std::fs::create_dir(&vault).unwrap();
|
||||
|
||||
let resolved = validate_mcp_bridge_vault_path(&vault).unwrap();
|
||||
assert_eq!(resolved, vault.canonicalize().unwrap());
|
||||
|
||||
let missing = dir.path().join("Missing Vault");
|
||||
let err = validate_mcp_bridge_vault_path(&missing).unwrap_err();
|
||||
assert!(err.contains("MCP bridge vault is not available"));
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, unix))]
|
||||
#[test]
|
||||
fn vault_asset_scope_roots_include_requested_symlink_path() {
|
||||
@@ -339,4 +746,18 @@ mod tests {
|
||||
assert_eq!(roots[0], canonical_vault.canonicalize().unwrap());
|
||||
assert!(roots.contains(&symlinked_vault));
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn missing_asset_scope_roots_keeps_previously_allowed_vaults() {
|
||||
let vault_a = PathBuf::from("/vault-a");
|
||||
let vault_b = PathBuf::from("/vault-b");
|
||||
let allowed_roots = vec![vault_a.clone()];
|
||||
|
||||
assert_eq!(
|
||||
missing_asset_scope_roots(&allowed_roots, &[vault_b.clone()]),
|
||||
vec![vault_b]
|
||||
);
|
||||
assert!(missing_asset_scope_roots(&allowed_roots, &[vault_a]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,49 +17,208 @@ pub enum McpStatus {
|
||||
|
||||
/// Find the `node` binary path at runtime.
|
||||
pub(crate) fn find_node() -> Result<PathBuf, String> {
|
||||
let output = Command::new("which")
|
||||
.arg("node")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run `which node`: {e}"))?;
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
let mut last_error = None;
|
||||
for path in node_binary_candidates() {
|
||||
match verify_node_version(&path) {
|
||||
Ok(()) => return Ok(path),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = fallback_node_path() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
Err("node not found in PATH or common install locations".into())
|
||||
Err(last_error.unwrap_or_else(|| "node not found in PATH or common install locations".into()))
|
||||
}
|
||||
|
||||
fn fallback_node_path() -> Option<PathBuf> {
|
||||
fn node_binary_candidates() -> Vec<PathBuf> {
|
||||
let mut candidates = find_node_on_path();
|
||||
candidates.extend(find_node_in_user_shell());
|
||||
candidates.extend(fallback_node_paths());
|
||||
candidates
|
||||
}
|
||||
|
||||
fn find_node_on_path() -> Vec<PathBuf> {
|
||||
node_lookup_command()
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| node_lookup_paths(&output.stdout))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn find_node_in_user_shell() -> Vec<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.filter_map(|shell| command_path_from_shell(&shell, "node"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn node_lookup_paths(stdout: &[u8]) -> Vec<PathBuf> {
|
||||
String::from_utf8_lossy(stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
let mut shells = Vec::new();
|
||||
if let Some(shell) = std::env::var_os("SHELL") {
|
||||
if !shell.is_empty() {
|
||||
shells.push(PathBuf::from(shell));
|
||||
}
|
||||
}
|
||||
shells.push(PathBuf::from("/bin/zsh"));
|
||||
shells.push(PathBuf::from("/bin/bash"));
|
||||
shells
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
||||
if output.status.success() {
|
||||
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_node_version(node: &Path) -> Result<(), String> {
|
||||
let output = crate::hidden_command(node)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run {} --version: {e}", node.display()))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"{} --version failed; install Node.js 18+ and make it available on PATH",
|
||||
node.display()
|
||||
));
|
||||
}
|
||||
|
||||
let raw_version = String::from_utf8_lossy(&output.stdout);
|
||||
let Some(major) = node_major_version(&raw_version) else {
|
||||
return Err(format!(
|
||||
"Cannot parse Node.js version from '{}'",
|
||||
raw_version.trim()
|
||||
));
|
||||
};
|
||||
if major < 18 {
|
||||
return Err(format!(
|
||||
"Node.js 18+ is required for Tolaria MCP tools; found {}",
|
||||
raw_version.trim()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn node_major_version(version: &str) -> Option<u32> {
|
||||
version
|
||||
.trim()
|
||||
.trim_start_matches('v')
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|major| major.parse().ok())
|
||||
}
|
||||
|
||||
fn node_lookup_command() -> Command {
|
||||
#[cfg(windows)]
|
||||
let mut command = crate::hidden_command("where.exe");
|
||||
#[cfg(not(windows))]
|
||||
let mut command = crate::hidden_command("which");
|
||||
|
||||
command.arg("node");
|
||||
command
|
||||
}
|
||||
|
||||
fn fallback_node_paths() -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
PathBuf::from("/opt/homebrew/bin/node"),
|
||||
PathBuf::from("/usr/local/bin/node"),
|
||||
];
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.push(home.join(".volta").join("bin").join("node"));
|
||||
|
||||
let nvm_dir = home.join(".nvm").join("versions").join("node");
|
||||
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
|
||||
let mut versions = entries
|
||||
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||
.collect::<Vec<_>>();
|
||||
versions.sort();
|
||||
versions.reverse();
|
||||
candidates.extend(
|
||||
versions
|
||||
.into_iter()
|
||||
.map(|version| version.join("bin").join("node")),
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(program_files) = std::env::var_os("ProgramFiles") {
|
||||
candidates.push(PathBuf::from(program_files).join("nodejs").join("node.exe"));
|
||||
}
|
||||
if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") {
|
||||
candidates.push(
|
||||
PathBuf::from(program_files_x86)
|
||||
.join("nodejs")
|
||||
.join("node.exe"),
|
||||
);
|
||||
}
|
||||
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
|
||||
candidates.push(
|
||||
PathBuf::from(local_app_data)
|
||||
.join("Programs")
|
||||
.join("nodejs")
|
||||
.join("node.exe"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.into_iter().find(|path| path.is_file())
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.extend(node_binary_candidates_for_home(&home));
|
||||
}
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|path| path.is_file())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn node_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
home.join(".local/share/mise/shims")
|
||||
.join(node_binary_name()),
|
||||
home.join(".mise").join("shims").join(node_binary_name()),
|
||||
home.join(".asdf").join("shims").join(node_binary_name()),
|
||||
home.join(".volta").join("bin").join(node_binary_name()),
|
||||
];
|
||||
|
||||
let nvm_dir = home.join(".nvm").join("versions").join("node");
|
||||
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
|
||||
let mut versions = entries
|
||||
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||
.collect::<Vec<_>>();
|
||||
versions.sort();
|
||||
versions.reverse();
|
||||
candidates.extend(
|
||||
versions
|
||||
.into_iter()
|
||||
.map(|version| version.join("bin").join("node")),
|
||||
);
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn node_binary_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"node.exe"
|
||||
} else {
|
||||
"node"
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the path to `mcp-server/`.
|
||||
@@ -70,36 +229,72 @@ pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
|
||||
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("mcp-server");
|
||||
if dev_path.join("ws-bridge.js").exists() {
|
||||
return Ok(std::fs::canonicalize(&dev_path).unwrap_or(dev_path));
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?;
|
||||
// On macOS the exe lives at Contents/MacOS/<binary>.
|
||||
// Resources are placed at Contents/Resources/ by Tauri.
|
||||
let release_path = exe
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.join("Resources").join("mcp-server"))
|
||||
.ok_or_else(|| "Cannot resolve mcp-server directory".to_string())?;
|
||||
if release_path.join("ws-bridge.js").exists() {
|
||||
return Ok(release_path);
|
||||
let appdir = std::env::var_os("APPDIR").map(PathBuf::from);
|
||||
let candidates = mcp_server_dir_candidates(&dev_path, &exe, appdir.as_deref());
|
||||
if let Some(path) = candidates
|
||||
.iter()
|
||||
.find(|path| mcp_server_dir_has_files(path))
|
||||
{
|
||||
return Ok(std::fs::canonicalize(path).unwrap_or_else(|_| path.clone()));
|
||||
}
|
||||
|
||||
let searched = candidates
|
||||
.iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(format!(
|
||||
"mcp-server not found at {} or {}",
|
||||
dev_path.display(),
|
||||
release_path.display()
|
||||
"mcp-server not found. Searched these paths: {searched}"
|
||||
))
|
||||
}
|
||||
|
||||
fn mcp_server_dir_candidates(
|
||||
dev_path: &Path,
|
||||
exe_path: &Path,
|
||||
appdir: Option<&Path>,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![dev_path.to_path_buf()];
|
||||
|
||||
if let Some(exe_dir) = exe_path.parent() {
|
||||
candidates.push(exe_dir.join("mcp-server"));
|
||||
if let Some(bundle_root) = exe_dir.parent() {
|
||||
candidates.push(bundle_root.join("Resources").join("mcp-server"));
|
||||
candidates.push(bundle_root.join("lib").join("tolaria").join("mcp-server"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(appdir) = appdir {
|
||||
candidates.push(
|
||||
appdir
|
||||
.join("usr")
|
||||
.join("lib")
|
||||
.join("tolaria")
|
||||
.join("mcp-server"),
|
||||
);
|
||||
}
|
||||
|
||||
candidates.push(
|
||||
PathBuf::from("/usr")
|
||||
.join("lib")
|
||||
.join("tolaria")
|
||||
.join("mcp-server"),
|
||||
);
|
||||
candidates
|
||||
}
|
||||
|
||||
fn mcp_server_dir_has_files(path: &Path) -> bool {
|
||||
path.join("index.js").is_file() && path.join("ws-bridge.js").is_file()
|
||||
}
|
||||
|
||||
/// Spawn the WebSocket bridge as a child process.
|
||||
pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
|
||||
pub fn spawn_ws_bridge(vault_path: impl AsRef<Path>) -> Result<Child, String> {
|
||||
let node = find_node()?;
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let script = server_dir.join("ws-bridge.js");
|
||||
let vault_path = vault_path.as_ref();
|
||||
|
||||
let child = Command::new(node)
|
||||
let child = crate::hidden_command(node)
|
||||
.arg(&script)
|
||||
.env("VAULT_PATH", vault_path)
|
||||
.env("WS_PORT", "9710")
|
||||
@@ -110,18 +305,28 @@ pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn ws-bridge: {e}"))?;
|
||||
|
||||
log::info!("ws-bridge spawned (pid: {})", child.id());
|
||||
log::info!(
|
||||
"ws-bridge spawned (pid: {}, vault: {})",
|
||||
child.id(),
|
||||
vault_path.display()
|
||||
);
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
fn mcp_config_paths() -> Vec<PathBuf> {
|
||||
[
|
||||
dirs::home_dir().map(|home| home.join(".claude").join("mcp.json")),
|
||||
dirs::home_dir().map(|home| home.join(".cursor").join("mcp.json")),
|
||||
dirs::home_dir()
|
||||
.map(|home| mcp_config_paths_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn mcp_config_paths_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".claude.json"),
|
||||
home.join(".claude").join("mcp.json"),
|
||||
home.join(".gemini").join("settings.json"),
|
||||
home.join(".cursor").join("mcp.json"),
|
||||
home.join(".config").join("mcp").join("mcp.json"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_registered_mcp_entry(config_path: &Path) -> Option<serde_json::Value> {
|
||||
@@ -146,6 +351,14 @@ fn entry_index_js_exists(entry: &serde_json::Value) -> bool {
|
||||
.is_some_and(|index_js| Path::new(index_js).exists())
|
||||
}
|
||||
|
||||
fn entry_uses_stdio(entry: &serde_json::Value) -> bool {
|
||||
entry["type"].as_str() == Some("stdio")
|
||||
}
|
||||
|
||||
fn entry_has_ui_port(entry: &serde_json::Value) -> bool {
|
||||
entry["env"]["WS_UI_PORT"].as_str() == Some("9711")
|
||||
}
|
||||
|
||||
fn entry_targets_vault(entry: &serde_json::Value, vault_path: &Path) -> bool {
|
||||
let Some(entry_vault_path) = entry["env"]["VAULT_PATH"].as_str() else {
|
||||
return false;
|
||||
@@ -162,14 +375,40 @@ fn entry_targets_vault(entry: &serde_json::Value, vault_path: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// Build the MCP server entry JSON for a given vault path and index.js path.
|
||||
fn build_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
|
||||
fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"command": "node",
|
||||
"type": "stdio",
|
||||
"command": node_command,
|
||||
"args": [index_js],
|
||||
"env": { "VAULT_PATH": vault_path }
|
||||
"env": {
|
||||
"VAULT_PATH": vault_path,
|
||||
"WS_UI_PORT": "9711"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result<String, String> {
|
||||
let mut servers = serde_json::Map::new();
|
||||
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
|
||||
let config = serde_json::json!({ "mcpServers": servers });
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("Failed to serialize MCP config snippet: {e}"))
|
||||
}
|
||||
|
||||
/// Build the exact MCP config JSON users can copy into compatible tools.
|
||||
pub fn mcp_config_snippet(vault_path: &str) -> Result<String, String> {
|
||||
let node = find_node().map_err(|e| {
|
||||
format!("Node.js 18+ is required on PATH before Tolaria can build MCP config: {e}")
|
||||
})?;
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
|
||||
let node_command = node.to_string_lossy().into_owned();
|
||||
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
|
||||
|
||||
build_mcp_config_snippet(&entry)
|
||||
}
|
||||
|
||||
/// Write MCP registration to a list of config file paths.
|
||||
/// Returns "registered" on first registration, "updated" if already present.
|
||||
fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf]) -> String {
|
||||
@@ -184,12 +423,16 @@ fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf])
|
||||
status.to_string()
|
||||
}
|
||||
|
||||
/// Register Tolaria as an MCP server in Claude Code and Cursor config files.
|
||||
/// Register Tolaria as an MCP server in external AI tool config files.
|
||||
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
|
||||
let node = find_node().map_err(|e| {
|
||||
format!("Node.js 18+ is required on PATH before Tolaria can register MCP tools: {e}")
|
||||
})?;
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
|
||||
let node_command = node.to_string_lossy().into_owned();
|
||||
|
||||
let entry = build_mcp_entry(&index_js, vault_path);
|
||||
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
|
||||
|
||||
Ok(register_mcp_to_configs(&entry, &mcp_config_paths()))
|
||||
}
|
||||
@@ -297,13 +540,16 @@ pub fn remove_mcp() -> String {
|
||||
/// Check whether the MCP server is properly installed and registered.
|
||||
///
|
||||
/// Returns `Installed` when the Tolaria entry exists for the active vault in
|
||||
/// Claude Code or Cursor config and the referenced index.js file is present.
|
||||
/// 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| {
|
||||
read_registered_mcp_entry(&config_path).is_some_and(|entry| {
|
||||
entry_index_js_exists(&entry) && entry_targets_vault(&entry, active_vault_path)
|
||||
entry_uses_stdio(&entry)
|
||||
&& entry_index_js_exists(&entry)
|
||||
&& entry_has_ui_port(&entry)
|
||||
&& entry_targets_vault(&entry, active_vault_path)
|
||||
})
|
||||
}) {
|
||||
McpStatus::Installed
|
||||
@@ -321,6 +567,51 @@ mod tests {
|
||||
serde_json::from_str(&raw).unwrap()
|
||||
}
|
||||
|
||||
fn temp_config_path(file_name: &str) -> (tempfile::TempDir, PathBuf) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join(file_name);
|
||||
(tmp, config_path)
|
||||
}
|
||||
|
||||
fn write_config_json(config_path: &Path, config: serde_json::Value) {
|
||||
std::fs::write(config_path, serde_json::to_string(&config).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn managed_server(index_js: &str, vault_path: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "stdio",
|
||||
"command": "node",
|
||||
"args": [index_js],
|
||||
"env": { "VAULT_PATH": vault_path, "WS_UI_PORT": "9711" }
|
||||
})
|
||||
}
|
||||
|
||||
fn test_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
|
||||
build_mcp_entry("node", index_js, vault_path)
|
||||
}
|
||||
|
||||
fn write_mcp_servers_config(config_path: &Path, servers: Vec<(&str, serde_json::Value)>) {
|
||||
let servers = servers
|
||||
.into_iter()
|
||||
.map(|(name, server)| (name.to_string(), server))
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
write_config_json(config_path, serde_json::json!({ "mcpServers": servers }));
|
||||
}
|
||||
|
||||
struct ExpectedMcpServer<'a> {
|
||||
index_js: &'a str,
|
||||
vault_path: &'a str,
|
||||
}
|
||||
|
||||
fn assert_registered_tolaria_server(
|
||||
config: &serde_json::Value,
|
||||
expected: ExpectedMcpServer<'_>,
|
||||
) {
|
||||
let server = &config["mcpServers"][MCP_SERVER_NAME];
|
||||
assert_eq!(server["args"][0], expected.index_js);
|
||||
assert_eq!(server["env"]["VAULT_PATH"], expected.vault_path);
|
||||
}
|
||||
|
||||
fn write_index_js(dir: &Path) -> PathBuf {
|
||||
let index_js = dir.join("index.js");
|
||||
std::fs::write(&index_js, "console.log('ok');").unwrap();
|
||||
@@ -329,29 +620,141 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_mcp_entry_produces_correct_json() {
|
||||
let entry = build_mcp_entry("/path/to/index.js", "/my/vault");
|
||||
assert_eq!(entry["command"], "node");
|
||||
let entry = build_mcp_entry("/usr/local/bin/node", "/path/to/index.js", "/my/vault");
|
||||
assert_eq!(entry["type"], "stdio");
|
||||
assert_eq!(entry["command"], "/usr/local/bin/node");
|
||||
assert_eq!(entry["args"][0], "/path/to/index.js");
|
||||
assert_eq!(entry["env"]["VAULT_PATH"], "/my/vault");
|
||||
assert_eq!(entry["env"]["WS_UI_PORT"], "9711");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_mcp_config_snippet_wraps_tolaria_server_entry() {
|
||||
let entry = test_mcp_entry("/path/to/index.js", "/my/vault");
|
||||
let snippet = build_mcp_config_snippet(&entry).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&snippet).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/path/to/index.js"
|
||||
);
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
|
||||
"/my/vault"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_lookup_paths_keep_non_empty_lines_in_order() {
|
||||
let stdout = b"\nC:\\Program Files\\nodejs\\node.exe\r\nC:\\Other\\node.exe\r\n";
|
||||
assert_eq!(
|
||||
node_lookup_paths(stdout),
|
||||
vec![
|
||||
PathBuf::from("C:\\Program Files\\nodejs\\node.exe"),
|
||||
PathBuf::from("C:\\Other\\node.exe"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("missing-node");
|
||||
let node = dir.path().join("node");
|
||||
std::fs::write(&node, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let stdout = format!("\n{}\n{}\n", missing.display(), node.display());
|
||||
|
||||
assert_eq!(first_existing_path(&stdout), Some(node));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_path_from_shell_finds_node_from_login_shell() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let node = dir.path().join("node");
|
||||
std::fs::write(&node, "#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let shell = dir.path().join("shell");
|
||||
std::fs::write(
|
||||
&shell,
|
||||
format!(
|
||||
"#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n",
|
||||
node.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
assert_eq!(command_path_from_shell(&shell, "node"), Some(node));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_major_version_accepts_current_node_output() {
|
||||
assert_eq!(node_major_version("v24.13.1\n"), Some(24));
|
||||
assert_eq!(node_major_version("18.19.0"), Some(18));
|
||||
assert_eq!(node_major_version("not-node"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_binary_candidates_include_shell_managed_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = node_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/share/mise/shims/node"),
|
||||
home.join(".asdf/shims/node"),
|
||||
home.join(".volta/bin/node"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_dir_candidates_prefer_exe_dir_before_macos_resources() {
|
||||
let dev_path = Path::new("/repo/mcp-server");
|
||||
let exe_path = Path::new("/Users/tester/AppData/Local/Tolaria/tolaria.exe");
|
||||
let candidates = mcp_server_dir_candidates(dev_path, exe_path, None);
|
||||
|
||||
let windows_dir = PathBuf::from("/Users/tester/AppData/Local/Tolaria/mcp-server");
|
||||
let macos_dir = PathBuf::from("/Users/tester/AppData/Local/Resources/mcp-server");
|
||||
let windows_pos = candidates
|
||||
.iter()
|
||||
.position(|path| path == &windows_dir)
|
||||
.unwrap();
|
||||
let macos_pos = candidates
|
||||
.iter()
|
||||
.position(|path| path == &macos_dir)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(candidates[0], dev_path);
|
||||
assert!(windows_pos < macos_pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_creates_new_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/test/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/test/vault");
|
||||
|
||||
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(!was_update);
|
||||
|
||||
let config = read_config(&config_path);
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/test/index.js"
|
||||
);
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
|
||||
"/test/vault"
|
||||
assert_registered_tolaria_server(
|
||||
&config,
|
||||
ExpectedMcpServer {
|
||||
index_js: "/test/index.js",
|
||||
vault_path: "/test/vault",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -360,10 +763,10 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
|
||||
let entry1 = build_mcp_entry("/test/index.js", "/vault/v1");
|
||||
let entry1 = test_mcp_entry("/test/index.js", "/vault/v1");
|
||||
upsert_mcp_config(&config_path, &entry1).unwrap();
|
||||
|
||||
let entry2 = build_mcp_entry("/test/index.js", "/vault/v2");
|
||||
let entry2 = test_mcp_entry("/test/index.js", "/vault/v2");
|
||||
let was_update = upsert_mcp_config(&config_path, &entry2).unwrap();
|
||||
assert!(was_update);
|
||||
|
||||
@@ -390,7 +793,7 @@ mod tests {
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
|
||||
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(was_update);
|
||||
|
||||
@@ -404,17 +807,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_other_servers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
let (_tmp, config_path) = temp_config_path("mcp.json");
|
||||
write_mcp_servers_config(
|
||||
&config_path,
|
||||
vec![(
|
||||
"other-server",
|
||||
serde_json::json!({ "command": "other", "args": [] }),
|
||||
)],
|
||||
);
|
||||
|
||||
let existing = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"other-server": { "command": "other", "args": [] }
|
||||
}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
|
||||
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
@@ -423,11 +825,64 @@ mod tests {
|
||||
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_other_top_level_settings() {
|
||||
let (_tmp, config_path) = temp_config_path(".claude.json");
|
||||
write_config_json(
|
||||
&config_path,
|
||||
serde_json::json!({
|
||||
"model": "sonnet",
|
||||
"theme": "dark",
|
||||
"mcpServers": {
|
||||
"other-server": { "command": "other", "args": [] }
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
|
||||
let config = read_config(&config_path);
|
||||
assert_eq!(config["model"], "sonnet");
|
||||
assert_eq!(config["theme"], "dark");
|
||||
assert!(config["mcpServers"]["other-server"].is_object());
|
||||
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_gemini_settings_json_fields() {
|
||||
let (_tmp, config_path) = temp_config_path("settings.json");
|
||||
write_config_json(
|
||||
&config_path,
|
||||
serde_json::json!({
|
||||
"theme": "GitHub",
|
||||
"mcpServers": {
|
||||
"other": { "command": "example" }
|
||||
}
|
||||
}),
|
||||
);
|
||||
let entry = test_mcp_entry("/gemini/index.js", "/gemini-vault");
|
||||
|
||||
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
let config = read_config(&config_path);
|
||||
|
||||
assert!(!was_update);
|
||||
assert_eq!(config["theme"], "GitHub");
|
||||
assert_eq!(config["mcpServers"]["other"]["command"], "example");
|
||||
assert_registered_tolaria_server(
|
||||
&config,
|
||||
ExpectedMcpServer {
|
||||
index_js: "/gemini/index.js",
|
||||
vault_path: "/gemini-vault",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_creates_parent_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("nested").join("dir").join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
|
||||
upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(config_path.exists());
|
||||
@@ -437,7 +892,7 @@ mod tests {
|
||||
fn register_mcp_to_configs_returns_registered_for_new() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("claude").join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
|
||||
let status = register_mcp_to_configs(&entry, &[config]);
|
||||
assert_eq!(status, "registered");
|
||||
@@ -447,7 +902,7 @@ mod tests {
|
||||
fn register_mcp_to_configs_returns_updated_for_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
|
||||
// First call
|
||||
register_mcp_to_configs(&entry, std::slice::from_ref(&config));
|
||||
@@ -491,20 +946,55 @@ mod tests {
|
||||
#[test]
|
||||
fn register_mcp_to_configs_writes_multiple_configs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let claude_user_cfg = tmp.path().join(".claude.json");
|
||||
let claude_cfg = tmp.path().join("claude").join("mcp.json");
|
||||
let gemini_cfg = tmp.path().join(".gemini").join("settings.json");
|
||||
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
|
||||
register_mcp_to_configs(&entry, &[claude_cfg.clone(), cursor_cfg.clone()]);
|
||||
register_mcp_to_configs(
|
||||
&entry,
|
||||
&[
|
||||
claude_user_cfg.clone(),
|
||||
claude_cfg.clone(),
|
||||
gemini_cfg.clone(),
|
||||
cursor_cfg.clone(),
|
||||
generic_cfg.clone(),
|
||||
],
|
||||
);
|
||||
|
||||
assert!(claude_user_cfg.exists());
|
||||
assert!(claude_cfg.exists());
|
||||
assert!(gemini_cfg.exists());
|
||||
assert!(cursor_cfg.exists());
|
||||
assert!(generic_cfg.exists());
|
||||
|
||||
let raw = std::fs::read_to_string(&claude_cfg).unwrap();
|
||||
let raw = std::fs::read_to_string(&claude_user_cfg).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_registered_tolaria_server(
|
||||
&config,
|
||||
ExpectedMcpServer {
|
||||
index_js: "/test/index.js",
|
||||
vault_path: "/vault",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_config_paths_for_home_includes_all_supported_config_paths() {
|
||||
let home = Path::new("/Users/tester");
|
||||
let paths = mcp_config_paths_for_home(home);
|
||||
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/test/index.js"
|
||||
paths,
|
||||
vec![
|
||||
home.join(".claude.json"),
|
||||
home.join(".claude").join("mcp.json"),
|
||||
home.join(".gemini").join("settings.json"),
|
||||
home.join(".cursor").join("mcp.json"),
|
||||
home.join(".config").join("mcp").join("mcp.json"),
|
||||
]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
@@ -512,14 +1002,14 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
std::fs::write(&config_path, "not valid json{{{{").unwrap();
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let result = upsert_mcp_config(&config_path, &entry);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_mcp_to_configs_handles_empty_list() {
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
// Empty config list — function should return "registered" (no existing)
|
||||
let status = register_mcp_to_configs(&entry, &[]);
|
||||
// With empty config list, there were no updates, so status should be "registered"
|
||||
@@ -528,23 +1018,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn read_registered_mcp_entry_prefers_primary_server_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
let config = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"tolaria": {
|
||||
"command": "node",
|
||||
"args": ["/primary/index.js"],
|
||||
"env": { "VAULT_PATH": "/primary" }
|
||||
},
|
||||
"laputa": {
|
||||
"command": "node",
|
||||
"args": ["/legacy/index.js"],
|
||||
"env": { "VAULT_PATH": "/legacy" }
|
||||
}
|
||||
}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
|
||||
let (_tmp, config_path) = temp_config_path("mcp.json");
|
||||
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"),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let entry = read_registered_mcp_entry(&config_path).unwrap();
|
||||
assert_eq!(entry["env"]["VAULT_PATH"], "/primary");
|
||||
@@ -552,18 +1039,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn read_registered_mcp_entry_uses_legacy_server_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
let config = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"laputa": {
|
||||
"command": "node",
|
||||
"args": ["/legacy/index.js"],
|
||||
"env": { "VAULT_PATH": "/legacy" }
|
||||
}
|
||||
}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
|
||||
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"),
|
||||
)],
|
||||
);
|
||||
|
||||
let entry = read_registered_mcp_entry(&config_path).unwrap();
|
||||
assert_eq!(entry["env"]["VAULT_PATH"], "/legacy");
|
||||
@@ -611,7 +1094,7 @@ mod tests {
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
std::fs::write(&config_path, "[]").unwrap();
|
||||
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let result = upsert_mcp_config(&config_path, &entry);
|
||||
assert!(matches!(result, Err(ref error) if error.contains("Config is not a JSON object")));
|
||||
}
|
||||
@@ -625,7 +1108,7 @@ mod tests {
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
|
||||
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
let result = upsert_mcp_config(&config_path, &entry);
|
||||
assert!(
|
||||
matches!(result, Err(ref error) if error.contains("mcpServers is not a JSON object"))
|
||||
|
||||
@@ -13,6 +13,8 @@ const FILE_QUICK_OPEN: &str = "file-quick-open";
|
||||
const FILE_QUICK_OPEN_ALIAS: &str = "file-quick-open-alias";
|
||||
const FILE_SAVE: &str = "file-save";
|
||||
|
||||
const EDIT_FIND_IN_NOTE: &str = "edit-find-in-note";
|
||||
const EDIT_REPLACE_IN_NOTE: &str = "edit-replace-in-note";
|
||||
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
|
||||
const EDIT_TOGGLE_NOTE_LIST_SEARCH: &str = "edit-toggle-note-list-search";
|
||||
const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor";
|
||||
@@ -62,6 +64,8 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
FILE_QUICK_OPEN,
|
||||
FILE_QUICK_OPEN_ALIAS,
|
||||
FILE_SAVE,
|
||||
EDIT_FIND_IN_NOTE,
|
||||
EDIT_REPLACE_IN_NOTE,
|
||||
EDIT_FIND_IN_VAULT,
|
||||
EDIT_TOGGLE_NOTE_LIST_SEARCH,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
@@ -112,6 +116,9 @@ const NOTE_DEPENDENT_IDS: &[&str] = &[
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
];
|
||||
|
||||
/// IDs of menu items that depend on the editor being the active surface.
|
||||
const EDITOR_FIND_DEPENDENT_IDS: &[&str] = &[EDIT_FIND_IN_NOTE, EDIT_REPLACE_IN_NOTE];
|
||||
|
||||
/// IDs of menu items that depend on the note list being the active surface.
|
||||
const NOTE_LIST_SEARCH_DEPENDENT_IDS: &[&str] = &[EDIT_TOGGLE_NOTE_LIST_SEARCH];
|
||||
|
||||
@@ -156,6 +163,11 @@ fn build_app_menu(app: &App) -> MenuResult {
|
||||
}
|
||||
|
||||
fn build_file_menu(app: &App) -> MenuResult {
|
||||
let quick_open_alias_label = if cfg!(target_os = "macos") {
|
||||
"Quick Open (Cmd+O)"
|
||||
} else {
|
||||
"Quick Open (Ctrl+O)"
|
||||
};
|
||||
let new_note = MenuItemBuilder::new("New Note")
|
||||
.id(FILE_NEW_NOTE)
|
||||
.accelerator("CmdOrCtrl+N")
|
||||
@@ -167,7 +179,7 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
.id(FILE_QUICK_OPEN)
|
||||
.accelerator("CmdOrCtrl+P")
|
||||
.build(app)?;
|
||||
let quick_open_alias = MenuItemBuilder::new("Quick Open (Cmd+O)")
|
||||
let quick_open_alias = MenuItemBuilder::new(quick_open_alias_label)
|
||||
.id(FILE_QUICK_OPEN_ALIAS)
|
||||
.accelerator("CmdOrCtrl+O")
|
||||
.build(app)?;
|
||||
@@ -186,6 +198,15 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
}
|
||||
|
||||
fn build_edit_menu(app: &App) -> MenuResult {
|
||||
let find_in_note = MenuItemBuilder::new("Find in Note")
|
||||
.id(EDIT_FIND_IN_NOTE)
|
||||
.accelerator("CmdOrCtrl+F")
|
||||
.enabled(false)
|
||||
.build(app)?;
|
||||
let replace_in_note = MenuItemBuilder::new("Replace in Note")
|
||||
.id(EDIT_REPLACE_IN_NOTE)
|
||||
.enabled(false)
|
||||
.build(app)?;
|
||||
let find_in_vault = MenuItemBuilder::new("Find in Vault")
|
||||
.id(EDIT_FIND_IN_VAULT)
|
||||
.accelerator("CmdOrCtrl+Shift+F")
|
||||
@@ -209,6 +230,8 @@ fn build_edit_menu(app: &App) -> MenuResult {
|
||||
.separator()
|
||||
.select_all()
|
||||
.separator()
|
||||
.item(&find_in_note)
|
||||
.item(&replace_in_note)
|
||||
.item(&find_in_vault)
|
||||
.item(&toggle_note_list_search)
|
||||
.item(&toggle_diff)
|
||||
@@ -320,7 +343,7 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.build(app)?;
|
||||
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
|
||||
.id(VIEW_TOGGLE_AI_CHAT)
|
||||
.accelerator("Cmd+Shift+L")
|
||||
.accelerator("CmdOrCtrl+Shift+L")
|
||||
.build(app)?;
|
||||
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
|
||||
.id(VIEW_TOGGLE_BACKLINKS)
|
||||
@@ -463,6 +486,11 @@ pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
|
||||
set_items_enabled(app_handle, NOTE_DEPENDENT_IDS, enabled);
|
||||
}
|
||||
|
||||
/// Enable or disable menu items that depend on the editor being the active surface.
|
||||
pub fn set_editor_find_items_enabled(app_handle: &AppHandle, enabled: bool) {
|
||||
set_items_enabled(app_handle, EDITOR_FIND_DEPENDENT_IDS, enabled);
|
||||
}
|
||||
|
||||
/// Enable or disable menu items that depend on the note list being the active surface.
|
||||
pub fn set_note_list_search_items_enabled(app_handle: &AppHandle, enabled: bool) {
|
||||
set_items_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_IDS, enabled);
|
||||
@@ -501,6 +529,8 @@ mod tests {
|
||||
FILE_NEW_TYPE,
|
||||
FILE_QUICK_OPEN,
|
||||
FILE_SAVE,
|
||||
EDIT_FIND_IN_NOTE,
|
||||
EDIT_REPLACE_IN_NOTE,
|
||||
EDIT_FIND_IN_VAULT,
|
||||
EDIT_TOGGLE_NOTE_LIST_SEARCH,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
@@ -559,6 +589,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_find_dependent_ids_are_subset_of_custom_ids() {
|
||||
for id in EDITOR_FIND_DEPENDENT_IDS {
|
||||
assert!(
|
||||
CUSTOM_IDS.contains(id),
|
||||
"editor-find-dependent ID {id} not in CUSTOM_IDS"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_dependent_ids_are_subset_of_custom_ids() {
|
||||
for id in GIT_COMMIT_DEPENDENT_IDS {
|
||||
|
||||
120
src-tauri/src/opencode_cli.rs
Normal file
120
src-tauri/src/opencode_cli.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
pub use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
crate::opencode_discovery::check_cli()
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = crate::opencode_discovery::find_binary()?;
|
||||
run_agent_stream_with_binary(&binary, request, emit)
|
||||
}
|
||||
|
||||
fn run_agent_stream_with_binary<F>(
|
||||
binary: &Path,
|
||||
request: AgentStreamRequest,
|
||||
emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let command = crate::opencode_config::build_command(binary, &request)?;
|
||||
crate::cli_agent_runtime::run_ai_agent_json_stream(
|
||||
command,
|
||||
"opencode",
|
||||
emit,
|
||||
crate::opencode_events::session_id,
|
||||
crate::opencode_events::dispatch_event,
|
||||
crate::opencode_events::format_error,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let script = dir.join("opencode");
|
||||
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
script
|
||||
}
|
||||
|
||||
fn request(vault_path: String) -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
message: "Summarize".into(),
|
||||
system_prompt: None,
|
||||
vault_path,
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_agent_stream_maps_opencode_json_events() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}'
|
||||
printf '%s\n' '{"type":"message","text":"Done"}'
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let session_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
request(vault.path().to_string_lossy().into_owned()),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session_id, "open_1");
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Init { session_id } if session_id == "open_1"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "Done"
|
||||
));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_agent_stream_reports_opencode_nonzero_exit_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}'
|
||||
printf '%s\n' 'provider login required' >&2
|
||||
exit 3
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let session_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
request(vault.path().to_string_lossy().into_owned()),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session_id, "open_1");
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AiAgentStreamEvent::Error { message } if message.contains("provider configured")
|
||||
)));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
}
|
||||
158
src-tauri/src/opencode_config.rs
Normal file
158
src-tauri/src/opencode_config.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
use crate::opencode_cli::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
|
||||
pub(crate) fn build_command(
|
||||
binary: &Path,
|
||||
request: &AgentStreamRequest,
|
||||
) -> Result<std::process::Command, String> {
|
||||
let mut command = crate::hidden_command(binary);
|
||||
command
|
||||
.args(build_args())
|
||||
.arg(build_prompt(request))
|
||||
.env(
|
||||
"OPENCODE_CONFIG_CONTENT",
|
||||
build_config(&request.vault_path, request.permission_mode)?,
|
||||
)
|
||||
.current_dir(&request.vault_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn build_args() -> Vec<String> {
|
||||
vec!["run".into(), "--format".into(), "json".into()]
|
||||
}
|
||||
|
||||
fn build_prompt(request: &AgentStreamRequest) -> String {
|
||||
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
|
||||
}
|
||||
|
||||
fn build_config(
|
||||
vault_path: &str,
|
||||
permission_mode: AiAgentPermissionMode,
|
||||
) -> Result<String, String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": permission_config(permission_mode),
|
||||
"mcp": {
|
||||
"tolaria": {
|
||||
"type": "local",
|
||||
"command": ["node", mcp_server_path],
|
||||
"environment": { "VAULT_PATH": vault_path },
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}))
|
||||
.map_err(|error| format!("Failed to serialize opencode config: {error}"))
|
||||
}
|
||||
|
||||
fn permission_config(permission_mode: AiAgentPermissionMode) -> serde_json::Value {
|
||||
let bash_permission = match permission_mode {
|
||||
AiAgentPermissionMode::Safe => "deny",
|
||||
AiAgentPermissionMode::PowerUser => "allow",
|
||||
};
|
||||
|
||||
serde_json::json!({
|
||||
"read": "allow",
|
||||
"edit": "allow",
|
||||
"glob": "allow",
|
||||
"grep": "allow",
|
||||
"list": "allow",
|
||||
"external_directory": "deny",
|
||||
"bash": bash_permission
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn request() -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_use_documented_safe_run_mode() {
|
||||
let args = build_args();
|
||||
|
||||
assert_eq!(args, vec!["run", "--format", "json"]);
|
||||
assert!(!args.contains(&"--dangerously-skip-permissions".to_string()));
|
||||
assert!(!args.contains(&"--dir".to_string()));
|
||||
assert!(!args.contains(&"--thinking".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_sets_vault_cwd_and_mcp_config() {
|
||||
let command = build_command(&PathBuf::from("opencode"), &request()).unwrap();
|
||||
let actual_args: Vec<&OsStr> = command.get_args().collect();
|
||||
let config_value = command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == OsStr::new("OPENCODE_CONFIG_CONTENT"))
|
||||
.and_then(|(_, value)| value);
|
||||
|
||||
assert_eq!(command.get_program(), OsStr::new("opencode"));
|
||||
assert_eq!(actual_args[0], OsStr::new("run"));
|
||||
assert_eq!(actual_args[1], OsStr::new("--format"));
|
||||
assert_eq!(actual_args[2], OsStr::new("json"));
|
||||
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
|
||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
|
||||
assert!(config_value.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_includes_permissions_and_tolaria_mcp_server() {
|
||||
if let Ok(config) =
|
||||
build_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe)
|
||||
{
|
||||
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
|
||||
assert_eq!(json["permission"]["edit"], "allow");
|
||||
assert_eq!(json["permission"]["external_directory"], "deny");
|
||||
assert_eq!(json["permission"]["bash"], "deny");
|
||||
assert_eq!(json["mcp"]["tolaria"]["type"], "local");
|
||||
assert_eq!(json["mcp"]["tolaria"]["command"][0], "node");
|
||||
assert_eq!(
|
||||
json["mcp"]["tolaria"]["environment"]["VAULT_PATH"],
|
||||
"/tmp/vault"
|
||||
);
|
||||
assert!(json["mcp"]["tolaria"]["command"][1]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with("index.js"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_user_config_allows_bash_but_keeps_external_directories_denied() {
|
||||
if let Ok(config) = build_config(
|
||||
"/tmp/vault",
|
||||
crate::ai_agents::AiAgentPermissionMode::PowerUser,
|
||||
) {
|
||||
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
|
||||
assert_eq!(json["permission"]["bash"], "allow");
|
||||
assert_eq!(json["permission"]["external_directory"], "deny");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_prompt(&AgentStreamRequest {
|
||||
system_prompt: Some("Be concise".into()),
|
||||
..request()
|
||||
});
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
}
|
||||
204
src-tauri/src/opencode_discovery.rs
Normal file
204
src-tauri/src/opencode_discovery.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
use crate::ai_agents::AiAgentAvailability;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn check_cli() -> AiAgentAvailability {
|
||||
match find_binary() {
|
||||
Ok(binary) => AiAgentAvailability {
|
||||
installed: true,
|
||||
version: crate::cli_agent_runtime::version_for_binary(&binary),
|
||||
},
|
||||
Err(_) => AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_binary_in_user_shell() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_existing_binary(opencode_binary_candidates()) {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
Err("OpenCode CLI not found. Install it: https://opencode.ai/docs/".into())
|
||||
}
|
||||
|
||||
fn find_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command(path_lookup_command())
|
||||
.arg("opencode")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_lookup_command() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
}
|
||||
}
|
||||
|
||||
fn find_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, "opencode"))
|
||||
}
|
||||
|
||||
fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
let mut shells = Vec::new();
|
||||
if let Some(shell) = std::env::var_os("SHELL") {
|
||||
if !shell.is_empty() {
|
||||
shells.push(PathBuf::from(shell));
|
||||
}
|
||||
}
|
||||
shells.push(PathBuf::from("/bin/zsh"));
|
||||
shells.push(PathBuf::from("/bin/bash"));
|
||||
shells
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
||||
if output.status.success() {
|
||||
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
candidates.into_iter().find(|candidate| candidate.exists())
|
||||
}
|
||||
|
||||
fn opencode_binary_candidates() -> Vec<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.map(|home| opencode_binary_candidates_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn opencode_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".local/bin/opencode"),
|
||||
home.join(".local/bin/opencode.exe"),
|
||||
home.join(".opencode/bin/opencode"),
|
||||
home.join(".opencode/bin/opencode.exe"),
|
||||
home.join(".local/share/mise/shims/opencode"),
|
||||
home.join(".local/share/mise/shims/opencode.exe"),
|
||||
home.join(".asdf/shims/opencode"),
|
||||
home.join(".asdf/shims/opencode.exe"),
|
||||
home.join(".npm-global/bin/opencode"),
|
||||
home.join(".npm-global/bin/opencode.cmd"),
|
||||
home.join(".npm-global/bin/opencode.exe"),
|
||||
home.join(".npm/bin/opencode"),
|
||||
home.join(".npm/bin/opencode.cmd"),
|
||||
home.join(".npm/bin/opencode.exe"),
|
||||
home.join(".bun/bin/opencode"),
|
||||
home.join(".bun/bin/opencode.exe"),
|
||||
home.join("AppData/Roaming/npm/opencode.cmd"),
|
||||
home.join("AppData/Roaming/npm/opencode.exe"),
|
||||
home.join("AppData/Local/pnpm/opencode.cmd"),
|
||||
home.join("AppData/Local/pnpm/opencode.exe"),
|
||||
home.join("scoop/shims/opencode.exe"),
|
||||
PathBuf::from("/usr/local/bin/opencode"),
|
||||
PathBuf::from("/opt/homebrew/bin/opencode"),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_supported_local_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = opencode_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/opencode"),
|
||||
home.join(".opencode/bin/opencode"),
|
||||
home.join(".local/share/mise/shims/opencode"),
|
||||
home.join(".asdf/shims/opencode"),
|
||||
home.join(".npm-global/bin/opencode"),
|
||||
home.join(".bun/bin/opencode"),
|
||||
PathBuf::from("/opt/homebrew/bin/opencode"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_windows_npm_and_toolchain_shims() {
|
||||
let home = PathBuf::from(r"C:\Users\alex");
|
||||
let candidates = opencode_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".npm-global/bin/opencode.cmd"),
|
||||
home.join(".npm-global/bin/opencode.exe"),
|
||||
home.join(".npm/bin/opencode.cmd"),
|
||||
home.join(".npm/bin/opencode.exe"),
|
||||
home.join("AppData/Roaming/npm/opencode.cmd"),
|
||||
home.join("AppData/Roaming/npm/opencode.exe"),
|
||||
home.join("AppData/Local/pnpm/opencode.cmd"),
|
||||
home.join("AppData/Local/pnpm/opencode.exe"),
|
||||
home.join("scoop/shims/opencode.exe"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_lookup_command_matches_current_platform() {
|
||||
let expected = if cfg!(windows) { "where" } else { "which" };
|
||||
|
||||
assert_eq!(path_lookup_command(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("missing-opencode");
|
||||
let opencode = dir.path().join("opencode");
|
||||
std::fs::write(&opencode, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let stdout = format!("\n{}\n{}\n", missing.display(), opencode.display());
|
||||
|
||||
assert_eq!(first_existing_path(&stdout), Some(opencode));
|
||||
}
|
||||
}
|
||||
176
src-tauri/src/opencode_events.rs
Normal file
176
src-tauri/src/opencode_events.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use crate::ai_agents::AiAgentStreamEvent;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_line<F>(
|
||||
line: Result<String, std::io::Error>,
|
||||
emit: &mut F,
|
||||
) -> Option<serde_json::Value>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
crate::cli_agent_runtime::parse_ai_agent_json_line(line, emit)
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if json["type"].as_str() == Some("session") {
|
||||
emit_session_event(json, emit);
|
||||
}
|
||||
|
||||
match json["type"].as_str().unwrap_or_default() {
|
||||
"message" | "text" => emit_text(json, emit),
|
||||
"reasoning" => emit_reasoning(json, emit),
|
||||
"tool_use" | "tool" => emit_tool_start(json, emit),
|
||||
"tool_result" | "tool_done" => emit_tool_done(json, emit),
|
||||
"error" => emit_error_event(json, emit),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn session_id(json: &serde_json::Value) -> Option<&str> {
|
||||
json["sessionID"]
|
||||
.as_str()
|
||||
.or_else(|| json["session_id"].as_str())
|
||||
.or_else(|| json["session"]["id"].as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn format_error(stderr_output: String, status: String) -> String {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
if is_auth_error(&lower) {
|
||||
return "OpenCode CLI is not authenticated or has no provider configured. Run `opencode auth login` or configure a provider in OpenCode, then retry.".into();
|
||||
}
|
||||
|
||||
if stderr_output.trim().is_empty() {
|
||||
format!("opencode exited with status {status}")
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_session_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if let Some(session_id) = session_id(json) {
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: session_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_text<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if let Some(text) = text_value(json) {
|
||||
emit(AiAgentStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_reasoning<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if let Some(text) = text_value(json) {
|
||||
emit(AiAgentStreamEvent::ThinkingDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_tool_start<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let tool_id = tool_id(json).unwrap_or("tool").to_string();
|
||||
let tool_name = tool_name(json).unwrap_or("tool").to_string();
|
||||
let input = tool_input(json);
|
||||
|
||||
emit(AiAgentStreamEvent::ToolStart {
|
||||
tool_name,
|
||||
tool_id,
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_tool_done<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let tool_id = tool_id(json).unwrap_or("tool").to_string();
|
||||
let output = tool_output(json);
|
||||
|
||||
emit(AiAgentStreamEvent::ToolDone { tool_id, output });
|
||||
}
|
||||
|
||||
fn emit_error_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if let Some(message) = message_value(json) {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: message.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_id(json: &serde_json::Value) -> Option<&str> {
|
||||
first_string_field(
|
||||
json,
|
||||
&["id", "toolID", "tool_id", "toolCallID", "toolCallId"],
|
||||
)
|
||||
}
|
||||
|
||||
fn text_value(json: &serde_json::Value) -> Option<&str> {
|
||||
first_string_field(json, &["text", "content", "message"])
|
||||
}
|
||||
|
||||
fn message_value(json: &serde_json::Value) -> Option<&str> {
|
||||
first_string_field(json, &["message", "error", "text"])
|
||||
}
|
||||
|
||||
fn tool_name(json: &serde_json::Value) -> Option<&str> {
|
||||
first_string_field(json, &["name", "tool", "toolName"])
|
||||
}
|
||||
|
||||
fn tool_input(json: &serde_json::Value) -> Option<String> {
|
||||
first_json_field(json, &["input", "args"]).map(|input| input.to_string())
|
||||
}
|
||||
|
||||
fn tool_output(json: &serde_json::Value) -> Option<String> {
|
||||
first_json_field(json, &["output", "result"]).map(display_json_value)
|
||||
}
|
||||
|
||||
fn first_string_field<'a>(json: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> {
|
||||
keys.iter()
|
||||
.find_map(|key| json[*key].as_str().or_else(|| json["part"][*key].as_str()))
|
||||
}
|
||||
|
||||
fn first_json_field<'a>(
|
||||
json: &'a serde_json::Value,
|
||||
keys: &[&str],
|
||||
) -> Option<&'a serde_json::Value> {
|
||||
keys.iter()
|
||||
.find_map(|key| json.get(*key).or_else(|| json["part"].get(*key)))
|
||||
}
|
||||
|
||||
fn display_json_value(value: &serde_json::Value) -> String {
|
||||
value
|
||||
.as_str()
|
||||
.map(|output| output.to_string())
|
||||
.unwrap_or_else(|| value.to_string())
|
||||
}
|
||||
|
||||
fn is_auth_error(lower: &str) -> bool {
|
||||
["auth", "login", "sign in", "api key", "provider"]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "opencode_events_tests.rs"]
|
||||
mod tests;
|
||||
190
src-tauri/src/opencode_events_tests.rs
Normal file
190
src-tauri/src/opencode_events_tests.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
use super::*;
|
||||
|
||||
fn dispatch_events(events: impl IntoIterator<Item = serde_json::Value>) -> Vec<AiAgentStreamEvent> {
|
||||
let mut mapped = Vec::new();
|
||||
|
||||
for event in events {
|
||||
dispatch_event(&event, &mut |event| mapped.push(event));
|
||||
}
|
||||
|
||||
mapped
|
||||
}
|
||||
|
||||
struct ToolExpectation<'a> {
|
||||
tool_id: &'a str,
|
||||
tool_name: &'a str,
|
||||
input: Option<&'a str>,
|
||||
output: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn assert_tool_pair(events: &[AiAgentStreamEvent], expected: ToolExpectation<'_>) {
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart {
|
||||
tool_name: actual_name,
|
||||
tool_id: actual_id,
|
||||
input: actual_input,
|
||||
} if actual_name == expected.tool_name
|
||||
&& actual_id == expected.tool_id
|
||||
&& actual_input.as_deref() == expected.input
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone {
|
||||
tool_id: actual_id,
|
||||
output: actual_output,
|
||||
} if actual_id == expected.tool_id && actual_output.as_deref() == expected.output
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
|
||||
let mut events = Vec::new();
|
||||
|
||||
let read_error = parse_line(Err(std::io::Error::other("broken pipe")), &mut |event| {
|
||||
events.push(event)
|
||||
});
|
||||
let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event));
|
||||
let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event));
|
||||
|
||||
assert!(read_error.is_none());
|
||||
assert!(blank.is_none());
|
||||
assert!(invalid.is_none());
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Error { message } if message.contains("broken pipe")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_session_reasoning_and_text() {
|
||||
let mut events = Vec::new();
|
||||
let started = serde_json::json!({ "type": "session", "sessionID": "ses_1" });
|
||||
let reasoning = serde_json::json!({ "type": "reasoning", "text": "Checking links" });
|
||||
let text = serde_json::json!({ "type": "message", "text": "Done" });
|
||||
|
||||
for event in [started, reasoning, text] {
|
||||
dispatch_event(&event, &mut |event| events.push(event));
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Init { session_id } if session_id == "ses_1"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[2],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "Done"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_part_backed_reasoning_and_text() {
|
||||
let mut events = Vec::new();
|
||||
let reasoning = serde_json::json!({
|
||||
"type": "reasoning",
|
||||
"part": { "type": "reasoning", "text": "Checking links" }
|
||||
});
|
||||
let text = serde_json::json!({
|
||||
"type": "text",
|
||||
"part": { "type": "text", "text": "Done from OpenCode" }
|
||||
});
|
||||
|
||||
for event in [reasoning, text] {
|
||||
dispatch_event(&event, &mut |event| events.push(event));
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "Done from OpenCode"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_tool_events() {
|
||||
let direct = dispatch_events([
|
||||
serde_json::json!({
|
||||
"type": "tool_use",
|
||||
"id": "tool_1",
|
||||
"name": "read",
|
||||
"input": { "path": "Note.md" }
|
||||
}),
|
||||
serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" }),
|
||||
]);
|
||||
let part_backed = dispatch_events([
|
||||
serde_json::json!({
|
||||
"type": "tool_use",
|
||||
"part": {
|
||||
"id": "prt_tool_1",
|
||||
"tool": "read",
|
||||
"input": { "path": "Note.md" }
|
||||
}
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"part": {
|
||||
"id": "prt_tool_1",
|
||||
"output": "ok"
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
assert_tool_pair(
|
||||
&direct,
|
||||
ToolExpectation {
|
||||
tool_id: "tool_1",
|
||||
tool_name: "read",
|
||||
input: Some(r#"{"path":"Note.md"}"#),
|
||||
output: Some("ok"),
|
||||
},
|
||||
);
|
||||
assert_tool_pair(
|
||||
&part_backed,
|
||||
ToolExpectation {
|
||||
tool_id: "prt_tool_1",
|
||||
tool_name: "read",
|
||||
input: Some(r#"{"path":"Note.md"}"#),
|
||||
output: Some("ok"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_error_events() {
|
||||
let mut events = Vec::new();
|
||||
let error = serde_json::json!({ "type": "error", "message": "provider failed" });
|
||||
|
||||
dispatch_event(&error, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Error { message } if message == "provider failed"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_explains_missing_auth_or_provider_setup() {
|
||||
let message = format_error(
|
||||
"provider auth failed: please login".into(),
|
||||
"exit status: 1".into(),
|
||||
);
|
||||
|
||||
assert!(message.contains("OpenCode CLI is not authenticated"));
|
||||
assert!(message.contains("opencode auth login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_uses_status_or_first_stderr_lines() {
|
||||
let empty = format_error(String::new(), "exit status: 2".into());
|
||||
let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into());
|
||||
|
||||
assert_eq!(empty, "opencode exited with status exit status: 2");
|
||||
assert_eq!(truncated, "line 1\nline 2\nline 3");
|
||||
}
|
||||
124
src-tauri/src/pi_cli.rs
Normal file
124
src-tauri/src/pi_cli.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
pub use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
crate::pi_discovery::check_cli()
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = crate::pi_discovery::find_binary()?;
|
||||
run_agent_stream_with_binary(&binary, request, emit)
|
||||
}
|
||||
|
||||
fn run_agent_stream_with_binary<F>(
|
||||
binary: &Path,
|
||||
request: AgentStreamRequest,
|
||||
emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let agent_dir = tempfile::Builder::new()
|
||||
.prefix("tolaria-pi-agent-")
|
||||
.tempdir()
|
||||
.map_err(|error| format!("Failed to create Pi config directory: {error}"))?;
|
||||
let command = crate::pi_config::build_command(binary, &request, agent_dir.path())?;
|
||||
crate::cli_agent_runtime::run_ai_agent_json_stream(
|
||||
command,
|
||||
"pi",
|
||||
emit,
|
||||
crate::pi_events::session_id,
|
||||
crate::pi_events::dispatch_event,
|
||||
crate::pi_events::format_error,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let script = dir.join("pi");
|
||||
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
script
|
||||
}
|
||||
|
||||
fn request(vault_path: String) -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
message: "Summarize".into(),
|
||||
system_prompt: None,
|
||||
vault_path,
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_agent_stream_maps_pi_json_events() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
r#"printf '%s\n' '{"type":"session","id":"pi_1"}'
|
||||
printf '%s\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Done"}}'
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let session_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
request(vault.path().to_string_lossy().into_owned()),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session_id, "pi_1");
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Init { session_id } if session_id == "pi_1"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "Done"
|
||||
));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_agent_stream_reports_pi_nonzero_exit_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
r#"printf '%s\n' '{"type":"session","id":"pi_1"}'
|
||||
printf '%s\n' 'api key login required' >&2
|
||||
exit 4
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let session_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
request(vault.path().to_string_lossy().into_owned()),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session_id, "pi_1");
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
|
||||
)));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
}
|
||||
169
src-tauri/src/pi_config.rs
Normal file
169
src-tauri/src/pi_config.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
use crate::pi_cli::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
|
||||
pub(crate) fn build_command(
|
||||
binary: &Path,
|
||||
request: &AgentStreamRequest,
|
||||
agent_dir: &Path,
|
||||
) -> Result<std::process::Command, String> {
|
||||
write_mcp_config(agent_dir, &request.vault_path, request.permission_mode)?;
|
||||
|
||||
let mut command = crate::hidden_command(binary);
|
||||
command
|
||||
.args(build_args())
|
||||
.arg(build_prompt(request))
|
||||
.env("PI_CODING_AGENT_DIR", agent_dir)
|
||||
.current_dir(&request.vault_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn build_args() -> Vec<String> {
|
||||
vec![
|
||||
"--mode".into(),
|
||||
"json".into(),
|
||||
"--no-session".into(),
|
||||
"--extension".into(),
|
||||
"npm:pi-mcp-adapter".into(),
|
||||
]
|
||||
}
|
||||
|
||||
fn build_prompt(request: &AgentStreamRequest) -> String {
|
||||
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
|
||||
}
|
||||
|
||||
fn write_mcp_config(
|
||||
agent_dir: &Path,
|
||||
vault_path: &str,
|
||||
permission_mode: AiAgentPermissionMode,
|
||||
) -> Result<(), String> {
|
||||
std::fs::create_dir_all(agent_dir)
|
||||
.map_err(|error| format!("Failed to create Pi agent directory: {error}"))?;
|
||||
let config = build_mcp_config(vault_path, permission_mode)?;
|
||||
std::fs::write(agent_dir.join("mcp.json"), config)
|
||||
.map_err(|error| format!("Failed to write Pi MCP config: {error}"))
|
||||
}
|
||||
|
||||
fn build_mcp_config(
|
||||
vault_path: &str,
|
||||
_permission_mode: AiAgentPermissionMode,
|
||||
) -> Result<String, String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"settings": {
|
||||
"toolPrefix": "none",
|
||||
"idleTimeout": 10
|
||||
},
|
||||
"mcpServers": {
|
||||
"tolaria": {
|
||||
"command": "node",
|
||||
"args": [mcp_server_path],
|
||||
"env": {
|
||||
"VAULT_PATH": vault_path,
|
||||
"WS_UI_PORT": "9711"
|
||||
},
|
||||
"lifecycle": "lazy",
|
||||
"directTools": true
|
||||
}
|
||||
}
|
||||
}))
|
||||
.map_err(|error| format!("Failed to serialize Pi MCP config: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn request() -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_use_documented_json_mode_with_mcp_adapter() {
|
||||
let args = build_args();
|
||||
|
||||
assert_eq!(args[0], "--mode");
|
||||
assert_eq!(args[1], "json");
|
||||
assert!(args.contains(&"--no-session".to_string()));
|
||||
assert!(args.contains(&"--extension".to_string()));
|
||||
assert!(args.contains(&"npm:pi-mcp-adapter".to_string()));
|
||||
assert!(!args.contains(&"--no-tools".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_sets_vault_cwd_closed_stdin_and_config_dir() {
|
||||
let agent_dir = tempfile::tempdir().unwrap();
|
||||
let command = build_command(&PathBuf::from("pi"), &request(), agent_dir.path()).unwrap();
|
||||
let actual_args: Vec<&OsStr> = command.get_args().collect();
|
||||
let config_dir = command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == OsStr::new("PI_CODING_AGENT_DIR"))
|
||||
.and_then(|(_, value)| value);
|
||||
|
||||
assert_eq!(command.get_program(), OsStr::new("pi"));
|
||||
assert_eq!(actual_args[0], OsStr::new("--mode"));
|
||||
assert_eq!(actual_args[1], OsStr::new("json"));
|
||||
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
|
||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
|
||||
assert_eq!(config_dir, Some(agent_dir.path().as_os_str()));
|
||||
assert!(agent_dir.path().join("mcp.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_config_includes_tolaria_server_for_active_vault() {
|
||||
if let Ok(config) =
|
||||
build_mcp_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe)
|
||||
{
|
||||
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
|
||||
assert_eq!(json["settings"]["toolPrefix"], "none");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["lifecycle"], "lazy");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["directTools"], true);
|
||||
assert_eq!(
|
||||
json["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
|
||||
"/tmp/vault"
|
||||
);
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"], "9711");
|
||||
assert!(json["mcpServers"]["tolaria"]["args"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with("index.js"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_user_mode_uses_the_same_pi_mcp_config_as_safe_mode() {
|
||||
let safe =
|
||||
build_mcp_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe).unwrap();
|
||||
let power = build_mcp_config(
|
||||
"/tmp/vault",
|
||||
crate::ai_agents::AiAgentPermissionMode::PowerUser,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(safe, power);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_prompt(&AgentStreamRequest {
|
||||
system_prompt: Some("Be concise".into()),
|
||||
..request()
|
||||
});
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
}
|
||||
308
src-tauri/src/pi_discovery.rs
Normal file
308
src-tauri/src/pi_discovery.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
use crate::ai_agents::AiAgentAvailability;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn check_cli() -> AiAgentAvailability {
|
||||
match find_binary() {
|
||||
Ok(binary) => AiAgentAvailability {
|
||||
installed: true,
|
||||
version: crate::cli_agent_runtime::version_for_binary(&binary),
|
||||
},
|
||||
Err(_) => AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_binary_in_user_shell() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_existing_binary(pi_binary_candidates()) {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
Err("Pi CLI not found. Install it: https://pi.dev".into())
|
||||
}
|
||||
|
||||
fn find_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command(path_lookup_command())
|
||||
.arg("pi")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_lookup_command() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
}
|
||||
}
|
||||
|
||||
fn find_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, "pi"))
|
||||
}
|
||||
|
||||
fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
let mut shells = Vec::new();
|
||||
if let Some(shell) = std::env::var_os("SHELL") {
|
||||
if !shell.is_empty() {
|
||||
shells.push(PathBuf::from(shell));
|
||||
}
|
||||
}
|
||||
shells.push(PathBuf::from("/bin/zsh"));
|
||||
shells.push(PathBuf::from("/bin/bash"));
|
||||
shells
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
||||
if output.status.success() {
|
||||
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
candidates.into_iter().find(|candidate| candidate.exists())
|
||||
}
|
||||
|
||||
fn pi_binary_candidates() -> Vec<PathBuf> {
|
||||
let mut candidates = pi_binary_candidates_from_env();
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.extend(pi_binary_candidates_for_home(&home));
|
||||
}
|
||||
|
||||
candidates.extend(pi_global_binary_candidates());
|
||||
candidates
|
||||
}
|
||||
|
||||
fn pi_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = pi_nvm_binary_candidates_for_home(home);
|
||||
candidates.extend([
|
||||
home.join(".local/bin/pi"),
|
||||
home.join(".local/bin/pi.exe"),
|
||||
home.join(".pi/bin/pi"),
|
||||
home.join(".pi/bin/pi.exe"),
|
||||
home.join(".local/share/mise/shims/pi"),
|
||||
home.join(".local/share/mise/shims/pi.exe"),
|
||||
home.join(".asdf/shims/pi"),
|
||||
home.join(".asdf/shims/pi.exe"),
|
||||
home.join(".npm-global/bin/pi"),
|
||||
home.join(".npm-global/bin/pi.cmd"),
|
||||
home.join(".npm-global/bin/pi.exe"),
|
||||
home.join(".npm/bin/pi"),
|
||||
home.join(".npm/bin/pi.cmd"),
|
||||
home.join(".npm/bin/pi.exe"),
|
||||
home.join(".bun/bin/pi"),
|
||||
home.join(".bun/bin/pi.exe"),
|
||||
home.join("AppData/Roaming/npm/pi.cmd"),
|
||||
home.join("AppData/Roaming/npm/pi.exe"),
|
||||
home.join("AppData/Local/pnpm/pi.cmd"),
|
||||
home.join("AppData/Local/pnpm/pi.exe"),
|
||||
home.join("scoop/shims/pi.exe"),
|
||||
]);
|
||||
candidates
|
||||
}
|
||||
|
||||
fn pi_global_binary_candidates() -> Vec<PathBuf> {
|
||||
vec![
|
||||
PathBuf::from("/usr/local/bin/pi"),
|
||||
PathBuf::from("/opt/homebrew/bin/pi"),
|
||||
]
|
||||
}
|
||||
|
||||
fn pi_binary_candidates_from_env() -> Vec<PathBuf> {
|
||||
let nvm_bin = std::env::var_os("NVM_BIN").map(PathBuf::from);
|
||||
let npm_config_prefix = std::env::var_os("npm_config_prefix").map(PathBuf::from);
|
||||
let npm_config_prefix_upper = std::env::var_os("NPM_CONFIG_PREFIX").map(PathBuf::from);
|
||||
|
||||
pi_binary_candidates_from_prefixes(nvm_bin, npm_config_prefix, npm_config_prefix_upper)
|
||||
}
|
||||
|
||||
fn pi_binary_candidates_from_prefixes(
|
||||
nvm_bin: Option<PathBuf>,
|
||||
npm_config_prefix: Option<PathBuf>,
|
||||
npm_config_prefix_upper: Option<PathBuf>,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Some(path) = nvm_bin.filter(|path| !path.as_os_str().is_empty()) {
|
||||
candidates.push(path.join("pi"));
|
||||
}
|
||||
if let Some(path) = npm_config_prefix.filter(|path| !path.as_os_str().is_empty()) {
|
||||
candidates.push(path.join("bin/pi"));
|
||||
}
|
||||
if let Some(path) = npm_config_prefix_upper.filter(|path| !path.as_os_str().is_empty()) {
|
||||
candidates.push(path.join("bin/pi"));
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn pi_nvm_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let versions_dir = home.join(".nvm/versions/node");
|
||||
let mut version_dirs = match std::fs::read_dir(versions_dir) {
|
||||
Ok(entries) => entries
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.collect::<Vec<_>>(),
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
version_dirs.sort_by(|left, right| right.file_name().cmp(&left.file_name()));
|
||||
version_dirs
|
||||
.into_iter()
|
||||
.map(|version_dir| version_dir.join("bin/pi"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_supported_local_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = pi_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/pi"),
|
||||
home.join(".pi/bin/pi"),
|
||||
home.join(".local/share/mise/shims/pi"),
|
||||
home.join(".asdf/shims/pi"),
|
||||
home.join(".npm-global/bin/pi"),
|
||||
home.join(".bun/bin/pi"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_windows_npm_and_toolchain_shims() {
|
||||
let home = PathBuf::from(r"C:\Users\alex");
|
||||
let candidates = pi_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".npm-global/bin/pi.cmd"),
|
||||
home.join(".npm-global/bin/pi.exe"),
|
||||
home.join(".npm/bin/pi.cmd"),
|
||||
home.join(".npm/bin/pi.exe"),
|
||||
home.join("AppData/Roaming/npm/pi.cmd"),
|
||||
home.join("AppData/Roaming/npm/pi.exe"),
|
||||
home.join("AppData/Local/pnpm/pi.cmd"),
|
||||
home.join("AppData/Local/pnpm/pi.exe"),
|
||||
home.join("scoop/shims/pi.exe"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_lookup_command_matches_current_platform() {
|
||||
let expected = if cfg!(windows) { "where" } else { "which" };
|
||||
|
||||
assert_eq!(path_lookup_command(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_nvm_node_version_installs() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let pi = home.path().join(".nvm/versions/node/v22.20.0/bin/pi");
|
||||
|
||||
std::fs::create_dir_all(pi.parent().unwrap()).unwrap();
|
||||
std::fs::write(&pi, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let candidates = pi_binary_candidates_for_home(home.path());
|
||||
|
||||
assert!(
|
||||
candidates.contains(&pi),
|
||||
"missing nvm candidate {}",
|
||||
pi.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_static_global_fallbacks() {
|
||||
let candidates = pi_global_binary_candidates();
|
||||
|
||||
assert!(candidates.contains(&PathBuf::from("/usr/local/bin/pi")));
|
||||
assert!(candidates.contains(&PathBuf::from("/opt/homebrew/bin/pi")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_env_provided_npm_and_nvm_prefixes() {
|
||||
let nvm_bin = PathBuf::from("/Users/alex/.nvm/versions/node/v22.20.0/bin");
|
||||
let npm_config_prefix = PathBuf::from("/Users/alex/.npm-global");
|
||||
let npm_config_prefix_upper = PathBuf::from("/Users/alex/.npm");
|
||||
|
||||
let candidates = pi_binary_candidates_from_prefixes(
|
||||
Some(nvm_bin.clone()),
|
||||
Some(npm_config_prefix.clone()),
|
||||
Some(npm_config_prefix_upper.clone()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
candidates,
|
||||
vec![
|
||||
nvm_bin.join("pi"),
|
||||
npm_config_prefix.join("bin/pi"),
|
||||
npm_config_prefix_upper.join("bin/pi"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("missing-pi");
|
||||
let pi = dir.path().join("pi");
|
||||
std::fs::write(&pi, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let stdout = format!("\n{}\n{}\n", missing.display(), pi.display());
|
||||
|
||||
assert_eq!(first_existing_path(&stdout), Some(pi));
|
||||
}
|
||||
}
|
||||
145
src-tauri/src/pi_events.rs
Normal file
145
src-tauri/src/pi_events.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use crate::ai_agents::AiAgentStreamEvent;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_line<F>(
|
||||
line: Result<String, std::io::Error>,
|
||||
emit: &mut F,
|
||||
) -> Option<serde_json::Value>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
crate::cli_agent_runtime::parse_ai_agent_json_line(line, emit)
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if json["type"].as_str() == Some("session") {
|
||||
emit_session_event(json, emit);
|
||||
}
|
||||
|
||||
match json["type"].as_str().unwrap_or_default() {
|
||||
"message_update" => emit_message_update(json, emit),
|
||||
"tool_execution_start" => emit_tool_start(json, emit),
|
||||
"tool_execution_end" => emit_tool_done(json, emit),
|
||||
"error" => emit_error_event(json, emit),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn session_id(json: &serde_json::Value) -> Option<&str> {
|
||||
json["id"]
|
||||
.as_str()
|
||||
.or_else(|| json["session_id"].as_str())
|
||||
.or_else(|| json["session"]["id"].as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn format_error(stderr_output: String, status: String) -> String {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
if is_auth_error(&lower) {
|
||||
return "Pi CLI is not authenticated. Run `pi /login` in your terminal or configure a provider API key, then retry.".into();
|
||||
}
|
||||
|
||||
if stderr_output.trim().is_empty() {
|
||||
format!("pi exited with status {status}")
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_session_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if let Some(session_id) = session_id(json) {
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: session_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_message_update<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let event = &json["assistantMessageEvent"];
|
||||
match event["type"].as_str().unwrap_or_default() {
|
||||
"text_delta" => emit_delta(event, emit, |text| AiAgentStreamEvent::TextDelta { text }),
|
||||
"thinking_delta" => emit_delta(event, emit, |text| AiAgentStreamEvent::ThinkingDelta {
|
||||
text,
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_delta<F>(
|
||||
json: &serde_json::Value,
|
||||
emit: &mut F,
|
||||
build: impl FnOnce(String) -> AiAgentStreamEvent,
|
||||
) where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if let Some(delta) = json["delta"].as_str() {
|
||||
emit(build(delta.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_tool_start<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
emit(AiAgentStreamEvent::ToolStart {
|
||||
tool_name: tool_name(json),
|
||||
tool_id: tool_id(json),
|
||||
input: json.get("args").map(|args| args.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_tool_done<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
emit(AiAgentStreamEvent::ToolDone {
|
||||
tool_id: tool_id(json),
|
||||
output: json.get("result").map(|result| result.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_error_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if let Some(message) = message_value(json) {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: message.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_name(json: &serde_json::Value) -> String {
|
||||
json["toolName"].as_str().unwrap_or("tool").to_string()
|
||||
}
|
||||
|
||||
fn tool_id(json: &serde_json::Value) -> String {
|
||||
json["toolCallId"].as_str().unwrap_or("tool").to_string()
|
||||
}
|
||||
|
||||
fn message_value(json: &serde_json::Value) -> Option<&str> {
|
||||
json["message"]
|
||||
.as_str()
|
||||
.or_else(|| json["error"].as_str())
|
||||
.or_else(|| json["text"].as_str())
|
||||
}
|
||||
|
||||
fn is_auth_error(lower: &str) -> bool {
|
||||
[
|
||||
"auth", "login", "sign in", "api key", "api.key", "provider", "401",
|
||||
]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pi_events_tests.rs"]
|
||||
mod tests;
|
||||
115
src-tauri/src/pi_events_tests.rs
Normal file
115
src-tauri/src/pi_events_tests.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
|
||||
let mut events = Vec::new();
|
||||
|
||||
let read_error = parse_line(Err(std::io::Error::other("broken pipe")), &mut |event| {
|
||||
events.push(event)
|
||||
});
|
||||
let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event));
|
||||
let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event));
|
||||
|
||||
assert!(read_error.is_none());
|
||||
assert!(blank.is_none());
|
||||
assert!(invalid.is_none());
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Error { message } if message.contains("broken pipe")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_session_thinking_and_text() {
|
||||
let mut events = Vec::new();
|
||||
let started = serde_json::json!({ "type": "session", "id": "pi-session" });
|
||||
let thinking = serde_json::json!({
|
||||
"type": "message_update",
|
||||
"assistantMessageEvent": { "type": "thinking_delta", "delta": "Checking links" }
|
||||
});
|
||||
let text = serde_json::json!({
|
||||
"type": "message_update",
|
||||
"assistantMessageEvent": { "type": "text_delta", "delta": "Done" }
|
||||
});
|
||||
|
||||
for event in [started, thinking, text] {
|
||||
dispatch_event(&event, &mut |event| events.push(event));
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Init { session_id } if session_id == "pi-session"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[2],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "Done"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_tool_events() {
|
||||
let mut events = Vec::new();
|
||||
let tool_start = serde_json::json!({
|
||||
"type": "tool_execution_start",
|
||||
"toolCallId": "tool_1",
|
||||
"toolName": "search_notes",
|
||||
"args": { "query": "today" }
|
||||
});
|
||||
let tool_done = serde_json::json!({
|
||||
"type": "tool_execution_end",
|
||||
"toolCallId": "tool_1",
|
||||
"toolName": "search_notes",
|
||||
"result": { "ok": true }
|
||||
});
|
||||
|
||||
dispatch_event(&tool_start, &mut |event| events.push(event));
|
||||
dispatch_event(&tool_done, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart { tool_name, tool_id, input }
|
||||
if tool_name == "search_notes" && tool_id == "tool_1" && input.as_deref() == Some(r#"{"query":"today"}"#)
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone { tool_id, output }
|
||||
if tool_id == "tool_1" && output.as_deref() == Some(r#"{"ok":true}"#)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_error_events() {
|
||||
let mut events = Vec::new();
|
||||
let error = serde_json::json!({ "type": "error", "message": "provider failed" });
|
||||
|
||||
dispatch_event(&error, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Error { message } if message == "provider failed"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_explains_missing_auth_or_provider_setup() {
|
||||
let message = format_error(
|
||||
"provider auth failed: api key required".into(),
|
||||
"exit status: 1".into(),
|
||||
);
|
||||
|
||||
assert!(message.contains("Pi CLI is not authenticated"));
|
||||
assert!(message.contains("pi /login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_uses_status_or_first_stderr_lines() {
|
||||
let empty = format_error(String::new(), "exit status: 2".into());
|
||||
let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into());
|
||||
|
||||
assert_eq!(empty, "pi exited with status exit status: 2");
|
||||
assert_eq!(truncated, "line 1\nline 2\nline 3");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -20,6 +20,14 @@ pub struct SearchResponse {
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
pub struct SearchOptions<'a> {
|
||||
pub vault_path: &'a str,
|
||||
pub query: &'a str,
|
||||
pub mode: &'a str,
|
||||
pub limit: usize,
|
||||
pub hide_gitignored_files: bool,
|
||||
}
|
||||
|
||||
fn extract_snippet(content: &str, query_lower: &str) -> String {
|
||||
let content_lower = content.to_lowercase();
|
||||
let pos = match content_lower.find(query_lower) {
|
||||
@@ -63,26 +71,45 @@ pub fn search_vault(
|
||||
_mode: &str,
|
||||
limit: usize,
|
||||
) -> Result<SearchResponse, String> {
|
||||
search_vault_with_options(SearchOptions {
|
||||
vault_path,
|
||||
query,
|
||||
mode: _mode,
|
||||
limit,
|
||||
hide_gitignored_files: crate::settings::hide_gitignored_files_enabled(),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_markdown_search_candidate(path: &Path) -> bool {
|
||||
if !path.extension().is_some_and(|ext| ext == "md") {
|
||||
return false;
|
||||
}
|
||||
|
||||
!path
|
||||
.components()
|
||||
.any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
|
||||
}
|
||||
|
||||
fn collect_markdown_paths(vault_dir: &Path, hide_gitignored_files: bool) -> Vec<PathBuf> {
|
||||
let paths = WalkDir::new(vault_dir)
|
||||
.into_iter()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.into_path())
|
||||
.filter(|path| is_markdown_search_candidate(path))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
crate::vault::filter_gitignored_paths(vault_dir, paths, hide_gitignored_files)
|
||||
}
|
||||
|
||||
pub fn search_vault_with_options(options: SearchOptions<'_>) -> Result<SearchResponse, String> {
|
||||
let start = Instant::now();
|
||||
let query_lower = query.to_lowercase();
|
||||
let vault_dir = Path::new(vault_path);
|
||||
let query_lower = options.query.to_lowercase();
|
||||
let vault_dir = Path::new(options.vault_path);
|
||||
|
||||
let mut results: Vec<SearchResult> = Vec::new();
|
||||
|
||||
for entry in WalkDir::new(vault_dir).into_iter().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
if !path.extension().is_some_and(|ext| ext == "md") {
|
||||
continue;
|
||||
}
|
||||
// Skip hidden dirs and .laputa config
|
||||
if path
|
||||
.components()
|
||||
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
for path in collect_markdown_paths(vault_dir, options.hide_gitignored_files) {
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
@@ -117,15 +144,15 @@ pub fn search_vault(
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
results.truncate(limit);
|
||||
results.truncate(options.limit);
|
||||
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
Ok(SearchResponse {
|
||||
results,
|
||||
elapsed_ms,
|
||||
query: query.to_string(),
|
||||
mode: "keyword".to_string(),
|
||||
query: options.query.to_string(),
|
||||
mode: options.mode.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -135,6 +162,14 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::Builder;
|
||||
|
||||
fn init_git_repo(root: &Path) {
|
||||
crate::hidden_command("git")
|
||||
.args(["init"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_basic() {
|
||||
let content = "line one\nline with keyword here\nline three";
|
||||
@@ -188,4 +223,38 @@ mod tests {
|
||||
assert_eq!(response.results.len(), 1);
|
||||
assert_eq!(response.results[0].title, "Updated Display Title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_vault_hides_gitignored_notes_when_enabled() {
|
||||
let dir = Builder::new()
|
||||
.prefix("search-gitignored-")
|
||||
.tempdir_in(std::env::current_dir().unwrap())
|
||||
.unwrap();
|
||||
init_git_repo(dir.path());
|
||||
fs::create_dir_all(dir.path().join("ignored")).unwrap();
|
||||
fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
|
||||
fs::write(dir.path().join("visible.md"), "# Visible\n\nneedle").unwrap();
|
||||
fs::write(dir.path().join("ignored/hidden.md"), "# Hidden\n\nneedle").unwrap();
|
||||
|
||||
let hidden = search_vault_with_options(SearchOptions {
|
||||
vault_path: dir.path().to_str().unwrap(),
|
||||
query: "needle",
|
||||
mode: "keyword",
|
||||
limit: 10,
|
||||
hide_gitignored_files: true,
|
||||
})
|
||||
.unwrap();
|
||||
let shown = search_vault_with_options(SearchOptions {
|
||||
vault_path: dir.path().to_str().unwrap(),
|
||||
query: "needle",
|
||||
mode: "keyword",
|
||||
limit: 10,
|
||||
hide_gitignored_files: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(hidden.results.len(), 1);
|
||||
assert_eq!(hidden.results[0].title, "Visible");
|
||||
assert_eq!(shown.results.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user