Compare commits
122 Commits
v0.2026041
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eeb37e32d | ||
|
|
193b7c2a44 | ||
|
|
a36760efe6 | ||
|
|
edd5a1ce8f | ||
|
|
991ee8689e | ||
|
|
e111700203 | ||
|
|
2772064b2a | ||
|
|
c0d16e4b4d | ||
|
|
711d49b3bb | ||
|
|
53b3f5a14b | ||
|
|
d1dbada9e4 | ||
|
|
4e66f09eef | ||
|
|
f5b1d763c1 | ||
|
|
905754e0c5 | ||
|
|
f665f089dc | ||
|
|
57a87e43b3 | ||
|
|
1b3a9e3ecf | ||
|
|
f8bd3e1b4a | ||
|
|
1b10aa9660 | ||
|
|
514c1f9ef7 | ||
|
|
e9c1ae2b8a | ||
|
|
e8e2004aa8 | ||
|
|
a448945ed5 | ||
|
|
d469f220e7 | ||
|
|
d60c47ee27 | ||
|
|
087e9f13c8 | ||
|
|
ed93b4b234 | ||
|
|
b8dfc79e80 | ||
|
|
bbf98d6112 | ||
|
|
f3ee593dee | ||
|
|
2bd5d64497 | ||
|
|
9c0bda55f6 | ||
|
|
2c0c464eac | ||
|
|
82192f23be | ||
|
|
310e159894 | ||
|
|
77a7d9d519 | ||
|
|
37f099ab1e | ||
|
|
8f72c4839d | ||
|
|
a1331453ad | ||
|
|
8518e89f03 | ||
|
|
a5cde9e2d6 | ||
|
|
2ad3d1cae1 | ||
|
|
7eaf57d040 | ||
|
|
e142b4c8f9 | ||
|
|
8f04ed59a4 | ||
|
|
8a5763959b | ||
|
|
4ab44fbe1a | ||
|
|
2720c14e22 | ||
|
|
8d3bfc2ca7 | ||
|
|
9ccd0a7221 | ||
|
|
1388e7ddbe | ||
|
|
cd76c0d65d | ||
|
|
d9fd615d4c | ||
|
|
1f4ae56c1c | ||
|
|
084d3d9c71 | ||
|
|
9041246a32 | ||
|
|
6a8fe11027 | ||
|
|
0078cc257a | ||
|
|
9f46f22667 | ||
|
|
2b734da4cb | ||
|
|
73c619892b | ||
|
|
8f1b3c75b2 | ||
|
|
39da825443 | ||
|
|
7b86e3a524 | ||
|
|
ab92ea178f | ||
|
|
ec3f90e865 | ||
|
|
2356afdc42 | ||
|
|
9154a8153f | ||
|
|
c0fbb1c40e | ||
|
|
76fe4611c2 | ||
|
|
62154535c0 | ||
|
|
cff9583a46 | ||
|
|
70252b6324 | ||
|
|
60da17e13b | ||
|
|
0c9bfb14c3 | ||
|
|
48d2246f0b | ||
|
|
d879457f89 | ||
|
|
311dcc6413 | ||
|
|
f61e7d2ab3 | ||
|
|
1ac6bd887b | ||
|
|
f31f827a4f | ||
|
|
a1ffe7a074 | ||
|
|
1b45394d0a | ||
|
|
95f0153c1b | ||
|
|
a034a79db2 | ||
|
|
1f148ebe32 | ||
|
|
b7cb3c2130 | ||
|
|
f50d9127d1 | ||
|
|
2a353d5749 | ||
|
|
c2f9b7c20d | ||
|
|
d540d76f73 | ||
|
|
cdfc0c9135 | ||
|
|
5b29e061fe | ||
|
|
5102923c9c | ||
|
|
87be231f9f | ||
|
|
557a45189d | ||
|
|
2107764550 | ||
|
|
0a52ae6c1d | ||
|
|
487e8d0352 | ||
|
|
d4aacf1bea | ||
|
|
71df363bb6 | ||
|
|
91ab5be914 | ||
|
|
9623203756 | ||
|
|
5897782a7e | ||
|
|
b4abc05f0e | ||
|
|
8f408d9ef0 | ||
|
|
1d778826e8 | ||
|
|
5120d75903 | ||
|
|
5d7426797f | ||
|
|
ed54cef69e | ||
|
|
df6f956cde | ||
|
|
61aae139c7 | ||
|
|
14dc07084d | ||
|
|
73c1ecf8a9 | ||
|
|
de91167a28 | ||
|
|
a4397d48de | ||
|
|
fbbd53d204 | ||
|
|
fd9de04275 | ||
|
|
a13e36a504 | ||
|
|
73a63085c7 | ||
|
|
739e1b3c20 | ||
|
|
e2745d96eb |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.7
|
||||
AVERAGE_THRESHOLD=9.36
|
||||
HOTSPOT_THRESHOLD=9.85
|
||||
AVERAGE_THRESHOLD=9.61
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Copy to .env.local and fill in real values
|
||||
# These are never committed — .env.local is gitignored
|
||||
# Copy to .env.local and fill in real values.
|
||||
# These are never committed — .env.local is gitignored.
|
||||
# Release workflows must mirror these values into GitHub Actions secrets:
|
||||
# - VITE_SENTRY_DSN
|
||||
# - SENTRY_DSN (same value as VITE_SENTRY_DSN for Rust-side crash reporting)
|
||||
# - VITE_POSTHOG_KEY
|
||||
# - VITE_POSTHOG_HOST
|
||||
|
||||
# Sentry DSN (https://sentry.io → Project → Settings → Client Keys)
|
||||
VITE_SENTRY_DSN=
|
||||
|
||||
2
.github/HOOKS.md
vendored
2
.github/HOOKS.md
vendored
@@ -49,7 +49,7 @@ If the remote CodeScene scores are better than the current thresholds, the hook
|
||||
|
||||
## Legacy Files
|
||||
|
||||
The legacy `pre-commit` and `post-commit` files under `.github/hooks/` are archival only. Do not copy them into `.git/hooks`; use Husky and `.husky/` instead. `install-hooks.sh` remains as a reinstall helper that runs Husky.
|
||||
The legacy `pre-commit` file under `.github/hooks/` is archival only. Do not copy it into `.git/hooks`; use Husky and `.husky/` instead. The old design `post-commit` auto-implementation hook was removed because it depended on obsolete one-off scripts. `install-hooks.sh` remains as a reinstall helper that runs Husky.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
26
.github/SETUP.md
vendored
26
.github/SETUP.md
vendored
@@ -14,6 +14,26 @@ Nel repository GitHub (Settings → Secrets and variables → Actions → New re
|
||||
**CODESCENE_PROJECT_ID**
|
||||
Trova l'ID del progetto nella dashboard CodeScene (URL: `https://codescene.io/projects/<PROJECT_ID>/...`)
|
||||
|
||||
**VITE_SENTRY_DSN**
|
||||
```
|
||||
<frontend Sentry DSN used by shipped Tolaria builds>
|
||||
```
|
||||
|
||||
**SENTRY_DSN**
|
||||
```
|
||||
<same DSN as VITE_SENTRY_DSN, passed to the Rust/Tauri build for native crash reporting>
|
||||
```
|
||||
|
||||
**VITE_POSTHOG_KEY**
|
||||
```
|
||||
<PostHog project API key used by shipped Tolaria builds>
|
||||
```
|
||||
|
||||
**VITE_POSTHOG_HOST**
|
||||
```
|
||||
https://eu.i.posthog.com
|
||||
```
|
||||
|
||||
### 2. Enable GitHub Actions
|
||||
|
||||
- Vai su Settings → Actions → General
|
||||
@@ -65,6 +85,12 @@ cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
|
||||
- **Fail se code health diminuisce**
|
||||
- Confronta HEAD vs base branch
|
||||
|
||||
### 📡 Telemetry In Release Builds
|
||||
- `release.yml` e `release-stable.yml` devono ricevere `VITE_SENTRY_DSN`, `SENTRY_DSN`, `VITE_POSTHOG_KEY`, `VITE_POSTHOG_HOST`
|
||||
- `VITE_SENTRY_DSN` inizializza il frontend Sentry bundle
|
||||
- `SENTRY_DSN` inizializza Sentry nel binary Rust/Tauri
|
||||
- `VITE_POSTHOG_KEY` / `VITE_POSTHOG_HOST` permettono ai build distribuiti di inizializzare PostHog quando l'utente abilita analytics
|
||||
|
||||
### 📝 Documentation
|
||||
- **Warning se modifichi `src/` o `src-tauri/` ma non aggiorni `docs/`**
|
||||
- Non blocca il merge, solo un reminder
|
||||
|
||||
72
.github/hooks/post-commit
vendored
72
.github/hooks/post-commit
vendored
@@ -1,72 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Post-commit hook: Auto-implement design changes via Claude Code
|
||||
# Copy to .git/hooks/post-commit and make executable
|
||||
|
||||
set -e
|
||||
|
||||
# Check if ui-design.pen was modified in this commit
|
||||
if ! git diff --name-only HEAD~1 HEAD | grep -q "ui-design.pen"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎨 Design file changed - analyzing..."
|
||||
|
||||
# Run analyzer
|
||||
ANALYZER_OUTPUT=$(node scripts/design-diff-analyzer.js --json 2>&1)
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ No implementation tasks needed (content-only changes)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract tasks from JSON
|
||||
TASKS_JSON=$(echo "$ANALYZER_OUTPUT" | sed -n '/---JSON---/,$ p' | tail -n +2)
|
||||
|
||||
if [ -z "$TASKS_JSON" ]; then
|
||||
echo "✅ No significant design changes"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$ANALYZER_OUTPUT" | sed '/---JSON---/,$ d'
|
||||
|
||||
# Generate Claude Code prompt
|
||||
PROMPT=$(cat <<EOF
|
||||
The design file ui-design.pen was just updated. Analyze the changes and implement them.
|
||||
|
||||
Design changes detected:
|
||||
$ANALYZER_OUTPUT
|
||||
|
||||
Tasks:
|
||||
1. Review the git diff of ui-design.pen (HEAD vs HEAD~1)
|
||||
2. Identify what changed (colors, typography, spacing, layout)
|
||||
3. Update the corresponding code:
|
||||
- Colors/Typography/Spacing → update src/theme.json
|
||||
- Layout changes → update React components
|
||||
- New elements → create new components
|
||||
4. Test visually in Chrome (pnpm dev, open localhost:5173)
|
||||
5. Run tests: pnpm test
|
||||
6. Commit changes with descriptive message referencing the design update
|
||||
|
||||
When completely finished, run:
|
||||
openclaw system event --text "✅ Design changes implemented and tested" --mode now
|
||||
EOF
|
||||
)
|
||||
|
||||
echo ""
|
||||
echo "🚀 Spawning Claude Code to implement changes..."
|
||||
echo ""
|
||||
|
||||
# Spawn Claude Code via OpenClaw
|
||||
openclaw sessions spawn \
|
||||
--task "$PROMPT" \
|
||||
--label "design-auto-implement" \
|
||||
--cleanup delete \
|
||||
--timeout-seconds 600 \
|
||||
--agent-id main
|
||||
|
||||
echo ""
|
||||
echo "✅ Claude Code spawned - you'll be notified when implementation is complete"
|
||||
echo ""
|
||||
|
||||
exit 0
|
||||
12
.github/workflows/README.md
vendored
12
.github/workflows/README.md
vendored
@@ -40,6 +40,18 @@ CODESCENE_PROJECT_ID=<your-project-id>
|
||||
Il PAT di CodeScene è lo stesso che usi localmente (~/.codescene/token).
|
||||
Il project ID lo trovi nella dashboard CodeScene.
|
||||
|
||||
### Telemetry Secrets For Release Builds
|
||||
Aggiungi anche questi secrets per i workflow `release.yml` e `release-stable.yml`:
|
||||
|
||||
```
|
||||
VITE_SENTRY_DSN=<frontend sentry dsn>
|
||||
SENTRY_DSN=<same dsn for rust/native crash reporting>
|
||||
VITE_POSTHOG_KEY=<posthog project api key>
|
||||
VITE_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
```
|
||||
|
||||
Senza questi valori, i build distribuiti possono mantenere i toggle telemetry nelle Settings ma non inizializzare davvero PostHog/Sentry.
|
||||
|
||||
### Coverage Thresholds
|
||||
Configura in `vitest.config.ts`:
|
||||
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -4,6 +4,10 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
|
||||
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Tests & Quality Checks
|
||||
@@ -37,9 +41,9 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
|
||||
@@ -1,31 +1,53 @@
|
||||
name: Release (Canary)
|
||||
name: Release (Stable)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
tags:
|
||||
- 'stable-v*'
|
||||
|
||||
env:
|
||||
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
|
||||
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
|
||||
|
||||
concurrency:
|
||||
group: release-canary-${{ github.ref }}
|
||||
group: release-stable-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 1: Compute the canary version string
|
||||
# Phase 1: Compute the stable version string once
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
version:
|
||||
name: Compute version
|
||||
name: Compute stable version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
display_version: ${{ steps.ver.outputs.display_version }}
|
||||
tag: ${{ steps.ver.outputs.tag }}
|
||||
steps:
|
||||
- id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="0.$(date -u +%Y%m%d).${GITHUB_RUN_NUMBER}-canary"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "### Canary version: \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
python3 <<'PY' > version.env
|
||||
import os
|
||||
import re
|
||||
from datetime import date
|
||||
|
||||
tag = os.environ["GITHUB_REF_NAME"]
|
||||
version = tag.removeprefix("stable-v")
|
||||
match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})", version)
|
||||
if not match:
|
||||
raise SystemExit(f"Stable tags must use stable-vYYYY.M.D, got {tag}")
|
||||
|
||||
date(*map(int, match.groups()))
|
||||
print(f"version={version}")
|
||||
print(f"display_version={version}")
|
||||
print(f"tag={tag}")
|
||||
PY
|
||||
|
||||
cat version.env >> "$GITHUB_OUTPUT"
|
||||
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
|
||||
echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 2: Build each architecture in parallel
|
||||
@@ -71,13 +93,17 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-release-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-
|
||||
${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ 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/${{ matrix.target }}/release/bundle
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
@@ -111,6 +137,10 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target ${{ matrix.target }}
|
||||
|
||||
@@ -131,10 +161,10 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 3: Publish GitHub Release (prerelease)
|
||||
# Phase 3: Publish GitHub Release
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: GitHub Release (canary)
|
||||
name: GitHub Release (stable)
|
||||
needs: [version, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -149,26 +179,26 @@ jobs:
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
PREV_TAG=$(git tag --sort=-version:refname | grep canary | head -n 1 || echo "")
|
||||
PREV_TAG=$(git tag --list 'stable-v*' --sort=-version:refname | grep -vx "${{ needs.version.outputs.tag }}" | head -n 1 || echo "")
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
NOTES=$(git log --oneline --no-merges -20)
|
||||
else
|
||||
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..HEAD")
|
||||
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..${{ needs.version.outputs.tag }}")
|
||||
fi
|
||||
{
|
||||
echo "## What's Changed (Canary)"
|
||||
echo "## What's Changed"
|
||||
echo ""
|
||||
echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "**Canary build — pre-release, may be unstable**"
|
||||
echo "**Stable release — manually promoted from \`main\`**"
|
||||
echo ""
|
||||
echo "**Requires Apple Silicon (M1/M2/M3)**"
|
||||
echo ""
|
||||
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
|
||||
echo "*Built from \`$(git rev-parse --short ${{ needs.version.outputs.tag }})\` on $(date -u +%Y-%m-%d)*"
|
||||
} > release_notes.md
|
||||
|
||||
- name: Build latest-canary.json
|
||||
- name: Build stable-latest.json
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
TAG="${{ needs.version.outputs.tag }}"
|
||||
@@ -179,10 +209,10 @@ jobs:
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
|
||||
cat > latest-canary.json << EOF
|
||||
cat > stable-latest.json << EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"notes": "Canary build. See ${PAGES_URL} for release notes.",
|
||||
"notes": "Stable release. See ${PAGES_URL} for full release notes.",
|
||||
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
@@ -192,24 +222,24 @@ jobs:
|
||||
}
|
||||
}
|
||||
EOF
|
||||
echo "latest-canary.json:"; cat latest-canary.json
|
||||
echo "stable-latest.json:"; cat stable-latest.json
|
||||
|
||||
- name: Publish GitHub Release (prerelease)
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: Tolaria ${{ needs.version.outputs.version }} (Canary)
|
||||
name: Tolaria ${{ needs.version.outputs.display_version }}
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: true
|
||||
prerelease: false
|
||||
files: |
|
||||
dmg-aarch64/*.dmg
|
||||
updater-aarch64/*.app.tar.gz
|
||||
updater-aarch64/*.app.tar.gz.sig
|
||||
latest-canary.json
|
||||
stable-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 4: Update GitHub Pages (preserve stable latest.json)
|
||||
# Phase 4: Update GitHub Pages
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
pages:
|
||||
name: Update release history page
|
||||
@@ -227,13 +257,16 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p _site
|
||||
mkdir -p _site/alpha _site/stable
|
||||
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
|
||||
# Download stable latest.json from existing GH Pages (preserve it)
|
||||
curl -fsSL "${PAGES_URL}/latest.json" -o _site/latest.json || echo '{}' > _site/latest.json
|
||||
# Copy canary latest.json from this release
|
||||
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "latest-canary.json" --output _site/latest-canary.json || true
|
||||
|
||||
curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
|
||||
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json
|
||||
|
||||
cp _site/alpha/latest.json _site/latest.json
|
||||
cp _site/alpha/latest.json _site/latest-canary.json
|
||||
|
||||
cat > _site/index.html << 'HTMLEOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -253,13 +286,13 @@ jobs:
|
||||
.release .downloads { margin-top: 0.75rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.release .downloads a { display: inline-block; padding: 0.375rem 0.75rem; background: #155DFF; color: #fff; border-radius: 6px; text-decoration: none; font-size: 0.8125rem; font-weight: 500; }
|
||||
.release .downloads a:hover { background: #1248CC; }
|
||||
.canary { border-left: 3px solid #f59e0b; }
|
||||
.alpha { border-left: 3px solid #f59e0b; }
|
||||
.empty { color: #787774; text-align: center; padding: 3rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Tolaria Release History</h1>
|
||||
<p class="subtitle">Auto-updated on every release</p>
|
||||
<p class="subtitle">Alpha builds update on every push to main. Stable builds appear when a stable-vYYYY.M.D tag is promoted.</p>
|
||||
<div id="releases"></div>
|
||||
<script>
|
||||
fetch('releases.json').then(r=>r.json()).then(releases=>{
|
||||
@@ -271,8 +304,8 @@ jobs:
|
||||
const links=dmgs.map(a=>'<a href="'+a.browser_download_url+'">'+a.name+'</a>').join('');
|
||||
const body=(r.body||'').replace(/</g,'<').replace(/>/g,'>');
|
||||
const div=document.createElement('div');
|
||||
div.className='release'+(r.prerelease?' canary':'');
|
||||
div.innerHTML='<h2>'+(r.name||r.tag_name)+'</h2><div class="meta">'+date+' · '+r.tag_name+(r.prerelease?' · <strong>Canary</strong>':'')+'</div><div class="body">'+body+'</div>'+(links?'<div class="downloads">'+links+'</div>':'');
|
||||
div.className='release'+(r.prerelease?' alpha':'');
|
||||
div.innerHTML='<h2>'+(r.name||r.tag_name)+'</h2><div class="meta">'+date+' · '+r.tag_name+(r.prerelease?' · <strong>Alpha</strong>':'')+'</div><div class="body">'+body+'</div>'+(links?'<div class="downloads">'+links+'</div>':'');
|
||||
el.appendChild(div);
|
||||
});
|
||||
});
|
||||
@@ -286,4 +319,4 @@ jobs:
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./_site
|
||||
commit_message: "Update release history for canary ${{ needs.version.outputs.tag }}"
|
||||
commit_message: "Update release history for ${{ needs.version.outputs.tag }}"
|
||||
147
.github/workflows/release.yml
vendored
147
.github/workflows/release.yml
vendored
@@ -1,31 +1,95 @@
|
||||
name: Release
|
||||
name: Release (Alpha)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
|
||||
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
group: release-alpha-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 1: Compute the version string once
|
||||
# Phase 1: Compute the alpha version string once
|
||||
# Alpha builds use calendar semver and stay newer than the latest stable tag.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
version:
|
||||
name: Compute version
|
||||
name: Compute alpha version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
display_version: ${{ steps.ver.outputs.display_version }}
|
||||
tag: ${{ steps.ver.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="0.$(date -u +%Y%m%d).${GITHUB_RUN_NUMBER}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "### Version: \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
python3 <<'PY' > version.env
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
def lines(command: list[str]) -> list[str]:
|
||||
output = subprocess.check_output(command, text=True).strip()
|
||||
return [line for line in output.splitlines() if line]
|
||||
|
||||
existing_tags = [
|
||||
tag for tag in lines(["git", "tag", "--points-at", "HEAD"])
|
||||
if tag.startswith("alpha-v")
|
||||
]
|
||||
|
||||
if existing_tags:
|
||||
tag = existing_tags[0]
|
||||
version = tag.removeprefix("alpha-v")
|
||||
else:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
stable_date = None
|
||||
stable_pattern = re.compile(r"^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$")
|
||||
|
||||
for stable_tag in lines(["git", "tag", "--list", "stable-v*", "--sort=-version:refname"]):
|
||||
match = stable_pattern.fullmatch(stable_tag)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
year, month, day = map(int, match.groups())
|
||||
try:
|
||||
stable_date = datetime(year, month, day, tzinfo=timezone.utc).date()
|
||||
except ValueError:
|
||||
continue
|
||||
break
|
||||
|
||||
alpha_date = today if stable_date is None or today > stable_date else stable_date + timedelta(days=1)
|
||||
calendar_version = f"{alpha_date.year}.{alpha_date.month}.{alpha_date.day}"
|
||||
sequence = len(lines(["git", "tag", "--list", f"alpha-v{calendar_version}-alpha.*"])) + 1
|
||||
|
||||
version = f"{calendar_version}-alpha.{sequence}"
|
||||
tag = f"alpha-v{version}"
|
||||
|
||||
display_match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)", version)
|
||||
display_version = (
|
||||
f"Alpha {int(display_match.group(1))}.{int(display_match.group(2))}.{int(display_match.group(3))}.{int(display_match.group(4))}"
|
||||
if display_match
|
||||
else version
|
||||
)
|
||||
|
||||
print(f"version={version}")
|
||||
print(f"display_version={display_version}")
|
||||
print(f"tag={tag}")
|
||||
PY
|
||||
|
||||
cat version.env >> "$GITHUB_OUTPUT"
|
||||
VERSION=$(grep '^version=' version.env | cut -d= -f2-)
|
||||
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
|
||||
echo "### Alpha version: \`$DISPLAY_VERSION\` (\`$VERSION\`)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 2: Build each architecture in parallel
|
||||
@@ -72,13 +136,17 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-release-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-
|
||||
${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ 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/${{ matrix.target }}/release/bundle
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
@@ -90,7 +158,6 @@ jobs:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
# Decode and import the certificate so codesign can use it in beforeBuildCommand
|
||||
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
|
||||
KEYCHAIN_PASSWORD="$(uuidgen)"
|
||||
@@ -113,15 +180,14 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target ${{ matrix.target }}
|
||||
|
||||
- name: Upload .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dmg-${{ matrix.arch }}
|
||||
path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
|
||||
retention-days: 1
|
||||
# Alpha releases only need the notarized app bundle and updater tarball.
|
||||
# Skipping DMG packaging avoids fragile bundle_dmg.sh failures on macOS runners.
|
||||
pnpm tauri build --target ${{ matrix.target }} --bundles app
|
||||
|
||||
- name: Upload updater artifacts (.tar.gz + .sig)
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -137,7 +203,7 @@ jobs:
|
||||
# No lipo/re-signing — use the per-arch artifacts directly
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: GitHub Release
|
||||
name: GitHub Release (alpha)
|
||||
needs: [version, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -152,24 +218,26 @@ jobs:
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
PREV_TAG=$(git tag --sort=-version:refname | head -n 1 || echo "")
|
||||
PREV_TAG=$(git tag --list 'alpha-v*' --sort=-version:refname | head -n 1 || echo "")
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
NOTES=$(git log --oneline --no-merges -20)
|
||||
else
|
||||
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..HEAD")
|
||||
fi
|
||||
{
|
||||
echo "## What's Changed"
|
||||
echo "## What's Changed (Alpha)"
|
||||
echo ""
|
||||
echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "**Alpha build — updated on every push to \`main\`**"
|
||||
echo ""
|
||||
echo "**Requires Apple Silicon (M1/M2/M3)**"
|
||||
echo ""
|
||||
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
|
||||
} > release_notes.md
|
||||
|
||||
- name: Build latest.json
|
||||
- name: Build alpha-latest.json
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
TAG="${{ needs.version.outputs.tag }}"
|
||||
@@ -180,10 +248,10 @@ jobs:
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
|
||||
cat > latest.json << EOF
|
||||
cat > alpha-latest.json << EOF
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"notes": "See ${PAGES_URL} for full release notes.",
|
||||
"notes": "Alpha build. See ${PAGES_URL} for full release notes.",
|
||||
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
@@ -193,21 +261,20 @@ jobs:
|
||||
}
|
||||
}
|
||||
EOF
|
||||
echo "latest.json:"; cat latest.json
|
||||
echo "alpha-latest.json:"; cat alpha-latest.json
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: Tolaria ${{ needs.version.outputs.version }}
|
||||
name: Tolaria ${{ needs.version.outputs.display_version }}
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
prerelease: true
|
||||
files: |
|
||||
dmg-aarch64/*.dmg
|
||||
updater-aarch64/*.app.tar.gz
|
||||
updater-aarch64/*.app.tar.gz.sig
|
||||
latest.json
|
||||
alpha-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 4: Update GitHub Pages with release history
|
||||
@@ -228,13 +295,16 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p _site
|
||||
mkdir -p _site/alpha _site/stable
|
||||
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
|
||||
# Copy latest.json to GitHub Pages for auto-updater endpoint
|
||||
gh release download --repo ${{ github.repository }} --pattern "latest.json" --output _site/latest.json || true
|
||||
# Preserve canary latest.json from existing GH Pages
|
||||
curl -fsSL "${PAGES_URL}/latest-canary.json" -o _site/latest-canary.json || true
|
||||
|
||||
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
|
||||
curl -fsSL "${PAGES_URL}/stable/latest.json" -o _site/stable/latest.json || echo '{}' > _site/stable/latest.json
|
||||
|
||||
cp _site/alpha/latest.json _site/latest.json
|
||||
cp _site/alpha/latest.json _site/latest-canary.json
|
||||
|
||||
cat > _site/index.html << 'HTMLEOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -254,12 +324,13 @@ jobs:
|
||||
.release .downloads { margin-top: 0.75rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.release .downloads a { display: inline-block; padding: 0.375rem 0.75rem; background: #155DFF; color: #fff; border-radius: 6px; text-decoration: none; font-size: 0.8125rem; font-weight: 500; }
|
||||
.release .downloads a:hover { background: #1248CC; }
|
||||
.alpha { border-left: 3px solid #f59e0b; }
|
||||
.empty { color: #787774; text-align: center; padding: 3rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Tolaria Release History</h1>
|
||||
<p class="subtitle">Auto-updated on every release</p>
|
||||
<p class="subtitle">Alpha builds update on every push to main. Stable builds appear when a stable-vYYYY.M.D tag is promoted.</p>
|
||||
<div id="releases"></div>
|
||||
<script>
|
||||
fetch('releases.json').then(r=>r.json()).then(releases=>{
|
||||
@@ -271,8 +342,8 @@ jobs:
|
||||
const links=dmgs.map(a=>'<a href="'+a.browser_download_url+'">'+a.name+'</a>').join('');
|
||||
const body=(r.body||'').replace(/</g,'<').replace(/>/g,'>');
|
||||
const div=document.createElement('div');
|
||||
div.className='release';
|
||||
div.innerHTML='<h2>'+(r.name||r.tag_name)+'</h2><div class="meta">'+date+' · '+r.tag_name+'</div><div class="body">'+body+'</div>'+(links?'<div class="downloads">'+links+'</div>':'');
|
||||
div.className='release'+(r.prerelease?' alpha':'');
|
||||
div.innerHTML='<h2>'+(r.name||r.tag_name)+'</h2><div class="meta">'+date+' · '+r.tag_name+(r.prerelease?' · <strong>Alpha</strong>':'')+'</div><div class="body">'+body+'</div>'+(links?'<div class="downloads">'+links+'</div>':'');
|
||||
el.appendChild(div);
|
||||
});
|
||||
});
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@@ -105,6 +105,16 @@ After any Tauri command, new component/hook, data model change, or new integrati
|
||||
|
||||
## 3. Product Rules
|
||||
|
||||
### Demo vault hygiene (`demo-vault/`, `demo-vault-v2/`)
|
||||
|
||||
Default to `demo-vault-v2/` for testing.
|
||||
|
||||
- Treat `demo-vault/` and `demo-vault-v2/` as disposable QA fixtures unless the task explicitly changes demo content.
|
||||
- If you create untracked notes, attachments, or other temporary files there for testing, delete them before the task is complete.
|
||||
- If you modify tracked demo-vault files only to test or QA behavior, revert those edits before the final commit.
|
||||
- Before declaring a task done, make sure `git status --short -- demo-vault demo-vault-v2` is empty unless demo fixture changes are part of the task.
|
||||
- If a fresh run starts and the only local dirt is inside `demo-vault/` or `demo-vault-v2/`, clean those paths first and continue. That case is recoverable QA residue, not a blocker.
|
||||
|
||||
### User vault (`~/Laputa/`)
|
||||
|
||||
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
|
||||
|
||||
@@ -23,10 +23,11 @@ These frontmatter field names have special meaning in Tolaria's UI:
|
||||
| `start_date:` + `end_date:` | Duration/timespan | Date range badge |
|
||||
| `goal:` + `result:` | Progress | Progress indicator in editor header |
|
||||
| `Workspace:` | Vault context filter | Global workspace filter |
|
||||
| `Belongs to:` | Parent relationship | Relationship chip in Properties panel |
|
||||
| `Related to:` | Lateral relationship | Relationship chip in Properties panel |
|
||||
| `belongs_to:` | Parent relationship | Humanized to `Belongs to` in the UI |
|
||||
| `related_to:` | Lateral relationship | Humanized to `Related to` in the UI |
|
||||
| `has:` | Contained relationship | Humanized to `Has` in the UI |
|
||||
|
||||
Relationship fields are detected dynamically — any frontmatter field containing `[[wikilink]]` values is treated as a relationship (see [ADR-0010](adr/0010-dynamic-wikilink-relationship-detection.md)).
|
||||
Relationship fields are detected dynamically — any frontmatter field containing `[[wikilink]]` values is treated as a relationship (see [ADR-0010](adr/0010-dynamic-wikilink-relationship-detection.md)). Tolaria's own default relationship vocabulary uses snake_case on disk, but labels are humanized at render time and existing user-authored keys are left untouched.
|
||||
|
||||
### System Properties (underscore convention)
|
||||
|
||||
@@ -146,12 +147,13 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
|
||||
├── weekly-review.md ← type: Procedure
|
||||
├── john-doe.md ← type: Person
|
||||
├── some-topic.md ← type: Topic
|
||||
├── AGENTS.md ← canonical Tolaria AI guidance
|
||||
├── CLAUDE.md ← compatibility shim pointing at AGENTS.md
|
||||
├── ...
|
||||
├── type/ ← type definition documents
|
||||
└── config/ ← meta-configuration files (agents.md, etc.)
|
||||
└── 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. The `type/` folder exists solely for type definition documents, and `config/` for configuration files.
|
||||
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. 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.
|
||||
|
||||
A `flatten_vault` migration command is available to move existing notes from type-based subfolders to the vault root.
|
||||
|
||||
@@ -271,7 +273,7 @@ type SidebarSelection =
|
||||
|
||||
1. Validates the path exists and is a directory
|
||||
2. Scans root-level `.md` files (non-recursive)
|
||||
3. Recursively scans protected folders: `type/`, `config/`, `attachments/`
|
||||
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()`:
|
||||
- Reads content with `fs::read_to_string()`
|
||||
@@ -336,6 +338,13 @@ interface ModifiedFile {
|
||||
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
|
||||
}
|
||||
|
||||
interface GitRemoteStatus {
|
||||
branch: string
|
||||
ahead: number
|
||||
behind: number
|
||||
hasRemote: boolean
|
||||
}
|
||||
|
||||
interface PulseCommit {
|
||||
hash: string
|
||||
shortHash: string
|
||||
@@ -372,12 +381,18 @@ interface PulseCommit {
|
||||
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
|
||||
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
|
||||
|
||||
`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
|
||||
- Keeps the normal push path unchanged for vaults that do have a remote
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
- **Modified file badges**: Orange dots in sidebar
|
||||
- **Diff view**: Toggle in breadcrumb bar → shows unified diff
|
||||
- **Git history**: Shown in Inspector panel for active note
|
||||
- **Commit dialog**: Triggered from sidebar or Cmd+K
|
||||
- **No remote indicator**: Neutral chip in the bottom bar when `GitRemoteStatus.hasRemote === false`
|
||||
- **Pulse view**: Activity feed when Pulse filter is selected
|
||||
- **Pull command**: Cmd+K → "Pull from Remote", also in Vault menu
|
||||
- **Git status popup**: Click sync badge → shows branch, ahead/behind, Pull button
|
||||
@@ -402,6 +417,23 @@ const WikiLink = createReactInlineContentSpec(
|
||||
)
|
||||
```
|
||||
|
||||
### Code Block Highlighting
|
||||
|
||||
Defined in `src/components/editorSchema.tsx` and styled in `src/components/EditorTheme.css`:
|
||||
|
||||
- The schema overrides BlockNote's default `codeBlock` spec with `createCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: "text" })` from `@blocknote/code-block`.
|
||||
- Fenced code blocks now use BlockNote's supported Shiki-backed highlighter path, which renders `.shiki` token spans directly inside the editor DOM.
|
||||
- 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.
|
||||
|
||||
### Formatting Surface Policy
|
||||
|
||||
Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`:
|
||||
|
||||
- `SingleEditorView` disables BlockNote's default formatting toolbar and `/` menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
|
||||
- The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing.
|
||||
- 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.
|
||||
|
||||
### Markdown-to-BlockNote Pipeline
|
||||
|
||||
```mermaid
|
||||
@@ -460,7 +492,7 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
|
||||
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
|
||||
|
||||
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, and all custom relationship fields as clickable wikilink chips.
|
||||
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, `has`, and all custom relationship fields as clickable wikilink chips. Relationship labels are humanized for display, but stored keys remain unchanged.
|
||||
|
||||
3. **BacklinksPanel**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
|
||||
|
||||
@@ -507,20 +539,39 @@ Per-vault settings stored locally and scoped by vault path:
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes, Inbox note-list column overrides, explicit organization workflow toggle
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
### AI Guidance Files
|
||||
|
||||
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`
|
||||
- `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
|
||||
- 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
|
||||
|
||||
`useOnboarding` hook detects first launch:
|
||||
- If vault path doesn't exist → show `WelcomeScreen`
|
||||
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen folder
|
||||
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen parent folder; Tolaria derives the final `Getting Started` child path before cloning
|
||||
- Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback)
|
||||
|
||||
### GitHub Integration
|
||||
`useGettingStartedClone` encapsulates the non-onboarding Getting Started action:
|
||||
- Opens the same parent-folder picker used by onboarding
|
||||
- Derives the final `.../Getting Started` destination path
|
||||
- Surfaces the resolved path through the app toast after a successful clone
|
||||
|
||||
Device Authorization Flow for GitHub-backed vaults:
|
||||
- `GitHubDeviceFlow` component handles OAuth
|
||||
- `GitHubVaultModal` for cloning existing repos or creating new ones
|
||||
- Token persisted in app settings for future git operations
|
||||
- `SettingsPanel` shows connection status with disconnect option
|
||||
`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
|
||||
- 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
|
||||
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors
|
||||
- No provider-specific token or username is stored in app settings
|
||||
|
||||
## Settings
|
||||
|
||||
@@ -528,15 +579,17 @@ App-level settings persisted at `~/.config/com.tolaria.app/settings.json` (reads
|
||||
|
||||
```typescript
|
||||
interface Settings {
|
||||
openai_key: string | null
|
||||
google_key: string | null
|
||||
github_token: string | null
|
||||
github_username: string | null
|
||||
auto_pull_interval_minutes: number | null
|
||||
telemetry_consent: boolean | null
|
||||
crash_reporting_enabled: boolean | null
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useSettings` hook and `SettingsPanel` component.
|
||||
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.
|
||||
|
||||
## Telemetry
|
||||
|
||||
@@ -559,8 +612,22 @@ Managed by `useSettings` hook and `SettingsPanel` component.
|
||||
## Updates & Feature Flags
|
||||
|
||||
### Hooks
|
||||
- **`useUpdater()`** — Checks for updates using the Tauri updater plugin. Automatic download and install.
|
||||
- **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_<name>`), then falls back to compile-time default. Type-safe via `FeatureFlagName` union.
|
||||
- **`useUpdater(releaseChannel)`** — Channel-aware updater state machine. Checks the selected feed, surfaces available/downloading/ready states, and delegates install work to Rust.
|
||||
- **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_<name>`), then falls back to telemetry-backed evaluation. Type-safe via `FeatureFlagName` union.
|
||||
|
||||
### Frontend helpers
|
||||
- **`src/lib/releaseChannel.ts`** — Normalizes persisted channel values so legacy or invalid settings fall back to Stable, while Stable serializes back to `null`.
|
||||
- **`src/lib/appUpdater.ts`** — Thin wrapper around the Tauri updater commands. Keeps the React hook free of endpoint-selection details.
|
||||
|
||||
### Rust
|
||||
- **`src-tauri/src/app_updater.rs`** — Chooses the correct update endpoint (`alpha/latest.json` or `stable/latest.json`) and adapts Tauri updater results into frontend-friendly payloads.
|
||||
- **`src-tauri/src/commands/version.rs`** — Formats app build/version labels for the status bar, including calendar alpha labels and legacy release compatibility.
|
||||
|
||||
### Tauri Commands
|
||||
- **`check_for_app_update`** — Channel-aware update manifest lookup.
|
||||
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
|
||||
|
||||
### CI/CD
|
||||
- **`.github/workflows/release.yml`** — Stable builds from `main`. Produces `latest.json` on GitHub Pages.
|
||||
- **`.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. 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`.
|
||||
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.
|
||||
|
||||
@@ -10,7 +10,7 @@ The vault is a folder of plain markdown files. The app never owns the data — i
|
||||
|
||||
### Convention over configuration
|
||||
|
||||
Tolaria is opinionated. Standard field names (`type:`, `status:`, `url:`, `Workspace:`, `Belongs to:`, `start_date:`, `end_date:`) have well-defined meanings and trigger specific UI behavior — without any setup. This is not convention *instead of* configuration: users can override defaults via config files in their vault (e.g. `config/relations.md`, `config/semantic-properties.md`). But the defaults work out of the box, and most users never need to touch them.
|
||||
Tolaria is opinionated. Standard field names (`type:`, `status:`, `url:`, `Workspace:`, `belongs_to:`, `related_to:`, `has:`, `start_date:`, `end_date:`) have well-defined meanings and trigger specific UI behavior — without any setup. Relationship defaults are stored in snake_case on disk and humanized in the UI. This is not convention *instead of* configuration: users can override defaults via config files in their vault (e.g. `config/relations.md`, `config/semantic-properties.md`). But the defaults work out of the box, and most users never need to touch them.
|
||||
|
||||
This principle directly serves AI-readability: the more structure comes from shared conventions rather than per-user custom configurations, the easier it is for an AI agent to understand and navigate the vault correctly — without needing bespoke instructions for every setup.
|
||||
|
||||
@@ -22,7 +22,7 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
|
||||
|-------------------|-----------------------------|
|
||||
| Type icon, type color | Editor zoom level |
|
||||
| Pinned properties per type | API keys (OpenAI, Google) |
|
||||
| Sidebar label overrides | GitHub token |
|
||||
| Sidebar label overrides | Auto-sync interval |
|
||||
| Property display order | Window size / position |
|
||||
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
|
||||
|
||||
@@ -89,6 +89,7 @@ flowchart LR
|
||||
| Desktop shell | Tauri v2 | 2.10.0 |
|
||||
| Frontend | React + TypeScript | React 19, TS 5.9 |
|
||||
| Editor | BlockNote | 0.46.2 |
|
||||
| Code block highlighting | @blocknote/code-block | 0.46.2 |
|
||||
| Raw editor | CodeMirror 6 | - |
|
||||
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
|
||||
| UI primitives | Radix UI + shadcn/ui | - |
|
||||
@@ -96,7 +97,7 @@ flowchart LR
|
||||
| Build | Vite | 7.3.1 |
|
||||
| Backend language | Rust (edition 2021) | 1.77.2 |
|
||||
| Frontmatter parsing | gray_matter | 0.2 |
|
||||
| AI (agent panel) | Claude CLI subprocess (streaming NDJSON) | - |
|
||||
| AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - |
|
||||
| Search | Keyword (walkdir-based file scan) | - |
|
||||
| MCP | @modelcontextprotocol/sdk | 1.0 |
|
||||
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
|
||||
@@ -114,7 +115,7 @@ flowchart TD
|
||||
NL["NoteList / PulseView\n(filtered list / activity)"]
|
||||
ED["Editor\n(BlockNote + diff + raw)"]
|
||||
IN["Inspector\n(metadata + relationships)"]
|
||||
AIP["AiPanel\n(Claude CLI agent + tools)"]
|
||||
AIP["AiPanel\n(selected CLI agent + tools)"]
|
||||
SP["SearchPanel\n(keyword search)"]
|
||||
ST["StatusBar\n(vault picker + sync + version)"]
|
||||
CP["CommandPalette\n(Cmd+K launcher)"]
|
||||
@@ -124,24 +125,27 @@ flowchart TD
|
||||
end
|
||||
|
||||
subgraph RB["Rust Backend"]
|
||||
LIB["lib.rs → 64 Tauri commands"]
|
||||
LIB["lib.rs → Tauri commands"]
|
||||
VAULT["vault/"]
|
||||
FM["frontmatter/"]
|
||||
GIT["git/"]
|
||||
GH["github/"]
|
||||
GIT["git/\n(commit, sync, clone)"]
|
||||
SETTINGS["settings.rs"]
|
||||
SEARCH["search.rs"]
|
||||
CLI["claude_cli.rs"]
|
||||
CLI["ai_agents.rs\n+ claude_cli.rs"]
|
||||
end
|
||||
|
||||
subgraph EXT["External Services"]
|
||||
CCLI["Claude CLI\n(agent subprocess)"]
|
||||
CCLI["Claude CLI / Codex CLI\n(agent subprocesses)"]
|
||||
MCP["MCP Server\n(ws://9710, 9711)"]
|
||||
GHAPI["GitHub API\n(OAuth, repos, clone)"]
|
||||
GCLI["git CLI\n(system executable)"]
|
||||
REMOTE["Git remotes\n(GitHub/GitLab/Gitea/etc.)"]
|
||||
end
|
||||
|
||||
FE -->|"Tauri IPC"| RB
|
||||
FE -->|"Vite Proxy / WS"| EXT
|
||||
CLI -->|"spawn subprocess"| CCLI
|
||||
LIB -->|"register / monitor"| MCP
|
||||
GIT -->|"clone / fetch / push / pull"| GCLI
|
||||
GCLI -->|"network auth via user config"| REMOTE
|
||||
end
|
||||
|
||||
style FE fill:#e8f4fd,stroke:#2196f3,color:#000
|
||||
@@ -174,11 +178,13 @@ flowchart TD
|
||||
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. 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 or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. 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. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, `useEditorSave`, `useRawMode`. 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 (Claude CLI subprocess 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).
|
||||
- **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. 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.
|
||||
|
||||
## 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).
|
||||
@@ -201,30 +207,32 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
|
||||
### AI Agent (AiPanel)
|
||||
|
||||
Full agent mode — spawns Claude CLI as a subprocess with tool access and MCP vault integration.
|
||||
Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration.
|
||||
|
||||
1. **Frontend** (`AiPanel` + `useAiAgent` hook) — streaming UI with reasoning blocks, tool action cards, and response display
|
||||
2. **Backend** (`claude_cli.rs`) — spawns `claude` binary with `--output-format stream-json`, parses NDJSON events
|
||||
3. **MCP Integration** — passes vault MCP config via `--mcp-config` flag so the agent can search, read, and modify vault notes
|
||||
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`
|
||||
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
|
||||
|
||||
#### Agent Event Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User (AiPanel)
|
||||
participant FE as useAiAgent (Frontend)
|
||||
participant R as claude_cli.rs (Rust)
|
||||
participant C as Claude CLI
|
||||
participant FE as useCliAiAgent (Frontend)
|
||||
participant R as ai_agents.rs (Rust)
|
||||
participant C as Selected CLI Agent
|
||||
participant V as Vault (MCP)
|
||||
|
||||
U->>FE: sendMessage(text, references)
|
||||
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
|
||||
FE->>R: invoke('stream_claude_agent', {message, systemPrompt, vaultPath})
|
||||
R->>C: spawn claude -p <msg> --output-format stream-json --mcp-config <json>
|
||||
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath})
|
||||
R->>R: pick adapter for claude_code or codex
|
||||
R->>C: spawn agent with MCP-enabled config
|
||||
|
||||
loop NDJSON stream
|
||||
C-->>R: Init | TextDelta | ThinkingDelta | ToolStart | ToolDone | Result | Done
|
||||
R-->>FE: emit("claude-agent-stream", event)
|
||||
loop Normalized stream
|
||||
C-->>R: Claude NDJSON or Codex JSONL events
|
||||
R-->>FE: emit("ai-agent-stream", event)
|
||||
alt TextDelta
|
||||
FE->>FE: accumulate response (revealed on Done)
|
||||
else ThinkingDelta
|
||||
@@ -245,7 +253,7 @@ sequenceDiagram
|
||||
|
||||
#### File Operation Detection
|
||||
|
||||
When the agent writes or edits vault files, `useAiAgent` detects this from tool inputs (Write/Edit tool JSON) and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload.
|
||||
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.
|
||||
|
||||
### Context Building
|
||||
|
||||
@@ -265,7 +273,7 @@ Token budget: 60% of 180k context limit (~108k tokens max). Active note gets pri
|
||||
|
||||
### Authentication
|
||||
|
||||
Claude CLI (agent mode) uses its own authentication — no API key configuration needed in Tolaria.
|
||||
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.
|
||||
|
||||
## MCP Server
|
||||
|
||||
@@ -420,6 +428,7 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
|
||||
- `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
|
||||
- `allNotes.noteListProperties`: Optional All Notes-only property chip override for the note list
|
||||
- `inbox.explicitOrganization`: When `false`, hide Inbox and the organized toggle so the vault behaves like a plain note collection
|
||||
|
||||
### Getting Started Vault
|
||||
@@ -427,25 +436,29 @@ 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
|
||||
- **Get started with a template** → pick a folder, then call `create_getting_started_vault()` to clone the public starter repo at runtime
|
||||
- **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
|
||||
|
||||
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` only holds the public GitHub URL and delegates the actual clone to the existing git backend.
|
||||
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.
|
||||
|
||||
### GitHub OAuth Integration
|
||||
`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.
|
||||
|
||||
Implements GitHub Device Authorization Flow for cloning/creating GitHub-backed vaults.
|
||||
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 config files (`AGENTS.md`, `CLAUDE.md`, `config.md`) so fresh starter vaults pick up the current default guidance 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. 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.
|
||||
|
||||
### Remote Clone & Auth Model
|
||||
|
||||
Tolaria no longer implements provider-specific OAuth or remote-repository APIs. All remote git work goes through the user's existing system git configuration.
|
||||
|
||||
**Flow:**
|
||||
1. User clicks "Login with GitHub" in Settings panel
|
||||
2. `github_device_flow_start()` returns a user code + verification URL
|
||||
3. User authorizes at `github.com/login/device`
|
||||
4. App polls `github_device_flow_poll()` until authorized
|
||||
5. Token stored in `~/.config/com.tolaria.app/settings.json`
|
||||
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`
|
||||
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
|
||||
|
||||
**Vault operations:**
|
||||
- `GitHubVaultModal`: Clone existing repo or create new private/public repo
|
||||
- `clone_repo()`: Clones with token-injected HTTPS URL
|
||||
- Token persists for future git push/pull operations
|
||||
**Auth model:**
|
||||
- SSH keys, Git Credential Manager, macOS Keychain helpers, `gh auth`, and other git helpers all work without app-specific setup
|
||||
- No provider tokens are stored in Tolaria settings
|
||||
- The same flow works for GitHub, GitLab, Bitbucket, Gitea, and self-hosted remotes
|
||||
|
||||
## Pulse View
|
||||
|
||||
@@ -519,8 +532,13 @@ flowchart TD
|
||||
PC -->|Fast-forward| RV["reload vault"]
|
||||
PC -->|Up to date| DONE["idle"]
|
||||
|
||||
MAN["Manual commit\n(CommitDialog)"] --> GC["invoke('git_commit', message)"]
|
||||
GC --> GP["invoke('git_push')"]
|
||||
MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"]
|
||||
RS --> RCHK["invoke('git_remote_status')"]
|
||||
RCHK --> RMODE{Remote configured?}
|
||||
RMODE -->|No| GC["invoke('git_commit', message)"]
|
||||
GC --> LOCAL["Local commit only\nNo remote chip + local toast"]
|
||||
RMODE -->|Yes| GC2["invoke('git_commit', message)"]
|
||||
GC2 --> GP["invoke('git_push')"]
|
||||
GP --> PR{Push result?}
|
||||
PR -->|ok| RM["Reload modified files"]
|
||||
PR -->|rejected| DIV["syncStatus = pull_required"]
|
||||
@@ -533,6 +551,8 @@ flowchart TD
|
||||
STATUS["Click sync badge"] --> POPUP["GitStatusPopup\n(branch, ahead/behind)"]
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
#### Sync States
|
||||
|
||||
| State | Indicator | Color | Trigger |
|
||||
@@ -556,7 +576,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
|
||||
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
|
||||
| `config_seed.rs` | Seeds `config/` folder, migrates `AGENTS.md`, repairs missing config files |
|
||||
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, repairs missing config files |
|
||||
| `getting_started.rs` | Creates the Getting Started demo vault |
|
||||
|
||||
## Rust Backend Modules
|
||||
@@ -565,10 +585,10 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|--------|---------|
|
||||
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
|
||||
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`) |
|
||||
| `github/` | GitHub OAuth + API (`auth.rs`, `api.rs`, `clone.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`) |
|
||||
| `search.rs` | Keyword search — walkdir-based vault file scan |
|
||||
| `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing |
|
||||
| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch |
|
||||
| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing |
|
||||
| `mcp.rs` | MCP server spawning + config registration |
|
||||
| `commands/` | Tauri command handlers (split into submodules) |
|
||||
| `settings.rs` | App settings persistence |
|
||||
@@ -576,7 +596,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `vault_list.rs` | Vault list persistence |
|
||||
| `menu.rs` | Native macOS menu bar |
|
||||
|
||||
## Tauri IPC Commands (65 total)
|
||||
## Tauri IPC Commands
|
||||
|
||||
### Vault Operations
|
||||
|
||||
@@ -593,7 +613,10 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
|
||||
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
|
||||
| `check_vault_exists` | Check if vault path exists |
|
||||
| `create_getting_started_vault` | Clone the public Getting Started vault into a chosen local folder |
|
||||
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, and `config.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 |
|
||||
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
|
||||
|
||||
### Frontmatter
|
||||
|
||||
@@ -620,17 +643,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `get_conflict_mode` | Get conflict resolution mode |
|
||||
| `get_vault_pulse` | Git activity feed (paginated) |
|
||||
| `get_last_commit_info` | Latest commit metadata |
|
||||
|
||||
### GitHub
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `github_device_flow_start` | Begin OAuth device flow |
|
||||
| `github_device_flow_poll` | Poll for authorization |
|
||||
| `github_get_user` | Get authenticated user info |
|
||||
| `github_list_repos` | List user's repos |
|
||||
| `github_create_repo` | Create new repo |
|
||||
| `clone_repo` | Clone repo with token auth |
|
||||
| `clone_repo` | Clone a remote repository into a local folder using system git |
|
||||
|
||||
### Search
|
||||
|
||||
@@ -653,6 +666,8 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `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 |
|
||||
| `check_mcp_status` | Check MCP registration state |
|
||||
|
||||
@@ -672,6 +687,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `copy_image_to_vault` | Copy image file to vault |
|
||||
| `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 |
|
||||
|
||||
`get_build_number` feeds the bottom status bar label. It preserves legacy `bNNN` date-build labels, renders local `0.1.0` / `0.0.0` builds as `dev`, formats calendar alpha builds as `Alpha YYYY.M.D.N`, strips any calendar `-stable.N` suffix back to `YYYY.M.D`, and keeps legacy semver releases readable instead of falling back to `?`.
|
||||
|
||||
## Mock Layer
|
||||
|
||||
@@ -696,16 +714,17 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, view mode | UI state |
|
||||
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
|
||||
| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` |
|
||||
| `useNoteCreation` | — | Note/type/daily-note creation with optimistic persistence |
|
||||
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
|
||||
| `useNoteRename` | — | Note renaming with wikilink update |
|
||||
| `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 |
|
||||
| `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation |
|
||||
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation |
|
||||
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
|
||||
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
|
||||
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
|
||||
| `useSettings` | App settings (API keys, GitHub token) | Persistent settings |
|
||||
| `useSettings` | App settings (telemetry, release channel, auto-sync interval) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
|
||||
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
|
||||
|
||||
@@ -747,24 +766,47 @@ Every push to `main` triggers `.github/workflows/release.yml`:
|
||||
|
||||
```
|
||||
push to main
|
||||
→ version job: compute 0.YYYYMMDD.RUN_NUMBER
|
||||
→ build job (matrix: aarch64 + x86_64):
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target <arch>
|
||||
→ upload .app, .tar.gz + .sig, .dmg as artifacts
|
||||
→ version job: compute calendar alpha version YYYY.M.D-alpha.N
|
||||
→ use today's UTC date unless the latest stable-vYYYY.M.D tag already uses today
|
||||
→ 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
|
||||
→ release job:
|
||||
→ download both arch artifacts
|
||||
→ lipo aarch64 + x86_64 → universal binary
|
||||
→ create universal .dmg + signed updater tarball
|
||||
→ generate latest.json (per-arch + universal platform entries)
|
||||
→ publish GitHub Release with all assets + auto-generated notes
|
||||
→ generate alpha-latest.json
|
||||
→ publish GitHub prerelease alpha-v<version> named Tolaria Alpha YYYY.M.D.N
|
||||
→ pages job:
|
||||
→ build static HTML release history page
|
||||
→ publish alpha/latest.json
|
||||
→ refresh latest.json + latest-canary.json as compatibility aliases to alpha
|
||||
→ preserve stable/latest.json
|
||||
→ deploy to gh-pages
|
||||
```
|
||||
|
||||
Stable promotions trigger `.github/workflows/release-stable.yml`:
|
||||
|
||||
```
|
||||
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
|
||||
→ release job:
|
||||
→ generate stable-latest.json
|
||||
→ publish GitHub release Tolaria YYYY.M.D
|
||||
→ pages job:
|
||||
→ publish stable/latest.json
|
||||
→ preserve alpha/latest.json
|
||||
→ deploy to gh-pages
|
||||
```
|
||||
|
||||
### Versioning
|
||||
|
||||
Format: `0.YYYYMMDD.GITHUB_RUN_NUMBER` (e.g. `0.20260223.42`). Stamped into `tauri.conf.json` and `Cargo.toml` dynamically.
|
||||
- Stable promotions use git tags in the form `stable-vYYYY.M.D` and stamp the technical version `YYYY.M.D`.
|
||||
- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`.
|
||||
- If the latest stable tag already uses today's date, alpha advances to the next calendar day before assigning `-alpha.N` so Alpha remains semver-newer than Stable across channel switches.
|
||||
- The workflows stamp the computed version into `tauri.conf.json` and `Cargo.toml` at build time.
|
||||
- This keeps display strings clean while preserving semver monotonicity when a user switches between Stable and Alpha.
|
||||
|
||||
### In-App Updates
|
||||
|
||||
@@ -823,17 +865,18 @@ sequenceDiagram
|
||||
|
||||
Tolaria uses the Tauri updater plugin for automatic updates:
|
||||
|
||||
- Builds from `main` branch are published as GitHub Releases
|
||||
- `latest.json` is published to GitHub Pages for the updater plugin
|
||||
- `useUpdater()` hook checks for updates automatically and supports download + install
|
||||
- `src-tauri/tauri.conf.json` points the default desktop feed at `stable/latest.json`
|
||||
- `useUpdater(releaseChannel)` waits 3 seconds after launch, then calls Rust commands instead of hard-coding one updater endpoint in the frontend
|
||||
- `src-tauri/src/app_updater.rs` maps the selected channel to `alpha/latest.json` or `stable/latest.json`
|
||||
- `download_and_install_app_update` streams progress events back into `UpdateBanner`
|
||||
|
||||
### Feature Flags (PostHog + Release Channels)
|
||||
|
||||
Feature flags are backed by PostHog and evaluated per release channel:
|
||||
|
||||
- **Alpha**: all features always enabled (no PostHog lookup)
|
||||
- **Beta**: sees features where the PostHog flag targets `release_channel = beta`
|
||||
- **Stable** (default): sees features where the flag targets `release_channel = stable`
|
||||
- **Stable** (default): PostHog rules decide which features are enabled
|
||||
- **Beta cohorts**: modeled in PostHog as tags or person-property targeting, not as a separate updater build or Settings option
|
||||
|
||||
```typescript
|
||||
import { useFeatureFlag } from './hooks/useFeatureFlag'
|
||||
@@ -843,14 +886,14 @@ const enabled = useFeatureFlag('example_flag') // boolean
|
||||
|
||||
**Resolution order:**
|
||||
1. `localStorage` override: key `ff_<name>` with value `"true"` or `"false"`
|
||||
2. `isFeatureEnabled(flag)` in `telemetry.ts` → checks release channel, then PostHog, then hardcoded defaults
|
||||
2. `isFeatureEnabled(flag)` in `telemetry.ts` → Alpha short-circuit, then PostHog, then hardcoded defaults
|
||||
|
||||
**How to add a new flag:**
|
||||
1. Add the flag name to the `FeatureFlagName` union type in `src/hooks/useFeatureFlag.ts`
|
||||
2. Create the flag on PostHog dashboard with rollout rules per channel
|
||||
2. Create the flag on PostHog with Stable rollout rules and any optional beta-cohort targeting
|
||||
3. Use `useFeatureFlag('your_flag')` in components
|
||||
|
||||
Release channel is selectable in Settings (alpha / beta / stable) and passed to PostHog as a person property via `identify()`. See ADR-0042.
|
||||
Release channel is selectable in Settings as `alpha` or `stable` and passed to PostHog as a person property via `identify()`. Beta targeting is managed in PostHog, not in the updater settings. See ADR-0057.
|
||||
|
||||
## Platform Support — iOS / iPadOS (Prototype)
|
||||
|
||||
@@ -859,7 +902,7 @@ Tauri v2 supports iOS as a beta target. The Rust backend cross-compiles to `aarc
|
||||
**Conditional compilation strategy:**
|
||||
|
||||
```
|
||||
#[cfg(desktop)] — git CLI, menu bar, MCP server, Claude CLI, updater
|
||||
#[cfg(desktop)] — git CLI, menu bar, MCP server, CLI AI agents, updater
|
||||
#[cfg(mobile)] — stub commands returning graceful errors or empty results
|
||||
```
|
||||
|
||||
@@ -868,8 +911,8 @@ 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)
|
||||
- GitHub operations (clone, list repos, device flow auth)
|
||||
- Claude CLI streaming (check, chat, agent)
|
||||
- Clone-by-URL via system git (`clone_repo`)
|
||||
- CLI AI agent streaming (Claude, Codex)
|
||||
- MCP registration and status
|
||||
- Menu state updates
|
||||
|
||||
|
||||
@@ -54,17 +54,17 @@ tolaria/
|
||||
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
|
||||
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
|
||||
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
|
||||
│ │ ├── AiPanel.tsx # AI agent (Claude CLI subprocess)
|
||||
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent)
|
||||
│ │ ├── AiMessage.tsx # Agent message display
|
||||
│ │ ├── AiActionCard.tsx # Agent tool action cards
|
||||
│ │ ├── AiAgentsOnboardingPrompt.tsx # First-launch AI agent installer prompt
|
||||
│ │ ├── SearchPanel.tsx # Search interface
|
||||
│ │ ├── SettingsPanel.tsx # App settings
|
||||
│ │ ├── StatusBar.tsx # Bottom bar: vault picker + sync
|
||||
│ │ ├── CommandPalette.tsx # Cmd+K command launcher
|
||||
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
|
||||
│ │ ├── WelcomeScreen.tsx # Onboarding screen
|
||||
│ │ ├── GitHubVaultModal.tsx # GitHub vault clone/create
|
||||
│ │ ├── GitHubDeviceFlow.tsx # GitHub OAuth device flow
|
||||
│ │ ├── CloneVaultModal.tsx # Clone a vault from any git URL
|
||||
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
|
||||
│ │ ├── CommitDialog.tsx # Git commit modal
|
||||
│ │ ├── CreateNoteDialog.tsx # New note modal
|
||||
@@ -83,9 +83,12 @@ tolaria/
|
||||
│ │ ├── useVaultSwitcher.ts # Multi-vault management
|
||||
│ │ ├── useVaultConfig.ts # Per-vault UI settings
|
||||
│ │ ├── useNoteActions.ts # Composes creation + rename + frontmatter
|
||||
│ │ ├── useNoteCreation.ts # Note/type/daily-note creation
|
||||
│ │ ├── useNoteCreation.ts # Note/type creation
|
||||
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
|
||||
│ │ ├── useAiAgent.ts # AI agent state + tool tracking
|
||||
│ │ ├── 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
|
||||
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
|
||||
│ │ ├── useAiActivity.ts # MCP UI bridge listener
|
||||
│ │ ├── useAutoSync.ts # Auto git pull/push
|
||||
│ │ ├── useConflictResolver.ts # Git conflict handling
|
||||
@@ -99,6 +102,7 @@ tolaria/
|
||||
│ │ ├── appCommandCatalog.ts # Shortcut combos + command metadata
|
||||
│ │ ├── appCommandDispatcher.ts # Shared shortcut/menu command IDs + dispatch
|
||||
│ │ ├── useSettings.ts # App settings
|
||||
│ │ ├── useGettingStartedClone.ts # Shared Getting Started clone action
|
||||
│ │ ├── useOnboarding.ts # First-launch flow
|
||||
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
|
||||
│ │ ├── useMcpBridge.ts # MCP WebSocket client
|
||||
@@ -122,6 +126,9 @@ tolaria/
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── lib/
|
||||
│ │ ├── aiAgents.ts # Shared agent registry + status helpers
|
||||
│ │ ├── appUpdater.ts # Frontend wrapper around channel-aware updater commands
|
||||
│ │ ├── releaseChannel.ts # Alpha/stable normalization helpers
|
||||
│ │ └── utils.ts # Tailwind merge + cn() helper
|
||||
│ │
|
||||
│ └── test/
|
||||
@@ -134,7 +141,7 @@ tolaria/
|
||||
│ ├── capabilities/ # Tauri v2 security capabilities
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # Entry point (calls lib::run())
|
||||
│ │ ├── lib.rs # Tauri setup + command registration (61 commands)
|
||||
│ │ ├── lib.rs # Tauri setup + command registration
|
||||
│ │ ├── commands/ # Tauri command handlers (split into modules)
|
||||
│ │ ├── vault/ # Vault module
|
||||
│ │ │ ├── mod.rs # Core types, parse_md_file, scan_vault
|
||||
@@ -147,14 +154,14 @@ tolaria/
|
||||
│ │ ├── frontmatter/ # Frontmatter module
|
||||
│ │ │ ├── mod.rs, yaml.rs, ops.rs
|
||||
│ │ ├── git/ # Git module
|
||||
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs
|
||||
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs, clone.rs
|
||||
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
|
||||
│ │ ├── github/ # GitHub module
|
||||
│ │ │ ├── mod.rs, auth.rs, api.rs, clone.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
|
||||
│ │ ├── mcp.rs # MCP server lifecycle + registration
|
||||
│ │ ├── app_updater.rs # Alpha/stable updater endpoint selection
|
||||
│ │ ├── settings.rs # App settings persistence
|
||||
│ │ ├── vault_config.rs # Per-vault UI config
|
||||
│ │ ├── vault_list.rs # Vault list persistence
|
||||
@@ -180,7 +187,7 @@ tolaria/
|
||||
├── playwright.config.ts # Full Playwright regression config
|
||||
├── playwright.smoke.config.ts # Curated pre-push Playwright config
|
||||
├── ui-design.pen # Master design file
|
||||
├── AGENTS.md # Shared project instructions for coding agents
|
||||
├── AGENTS.md # Canonical shared instructions for coding agents
|
||||
├── CLAUDE.md # Claude Code compatibility shim importing AGENTS.md
|
||||
└── docs/ # This documentation
|
||||
```
|
||||
@@ -202,7 +209,8 @@ tolaria/
|
||||
|------|---------------|
|
||||
| `src/hooks/useVaultLoader.ts` | How vault data is loaded and managed. The Tauri/mock branching pattern. |
|
||||
| `src/hooks/useNoteActions.ts` | Orchestrates note operations: composes `useNoteCreation`, `useNoteRename`, frontmatter CRUD, and wikilink navigation. |
|
||||
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, and restoring the cloned Getting Started vault. |
|
||||
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, and persisting cloned vaults in the switcher list. |
|
||||
| `src/hooks/useGettingStartedClone.ts` | Shared "Clone Getting Started Vault" action for the status bar and command palette. |
|
||||
| `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. |
|
||||
|
||||
### Backend
|
||||
@@ -212,17 +220,21 @@ tolaria/
|
||||
| `src-tauri/src/vault/mod.rs` | Vault scanning, frontmatter parsing, entity type inference, relationship extraction. |
|
||||
| `src-tauri/src/vault/cache.rs` | Git-based incremental caching — how large vaults load fast. |
|
||||
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
|
||||
| `src-tauri/src/git/` | All git operations (commit, pull, push, conflicts, pulse). |
|
||||
| `src-tauri/src/github/` | GitHub OAuth device flow + repo clone/create. |
|
||||
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse). |
|
||||
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
|
||||
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, Codex adapter, and stream normalization. |
|
||||
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
|
||||
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
|
||||
|
||||
### Editor
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/components/Editor.tsx` | BlockNote setup, breadcrumb bar, diff/raw toggle. |
|
||||
| `src/components/SingleEditorView.tsx` | Shared BlockNote shell, Tolaria formatting controllers, and suggestion menus. |
|
||||
| `src/components/editorSchema.tsx` | Custom wikilink inline content type definition. |
|
||||
| `src/components/tolariaEditorFormatting.tsx` | Markdown-safe formatting toolbar surface for BlockNote. |
|
||||
| `src/components/tolariaEditorFormattingConfig.ts` | Filters toolbar and slash-menu commands to markdown-roundtrippable actions. |
|
||||
| `src/utils/wikilinks.ts` | Wikilink preprocessing pipeline (markdown ↔ BlockNote). |
|
||||
| `src/components/RawEditorView.tsx` | CodeMirror 6 raw markdown editor. |
|
||||
|
||||
@@ -230,8 +242,9 @@ tolaria/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/components/AiPanel.tsx` | AI agent panel — Claude CLI with tool execution, reasoning, actions. |
|
||||
| `src/hooks/useAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. |
|
||||
| `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/lib/aiAgents.ts` | Supported agent definitions, status normalization, and default-agent helpers. |
|
||||
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
|
||||
|
||||
### Styling
|
||||
@@ -245,9 +258,13 @@ tolaria/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/hooks/useSettings.ts` | App settings (API keys, GitHub token, sync interval). |
|
||||
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, 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 including GitHub OAuth connection and the vault-level explicit organization toggle. |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, 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
|
||||
|
||||
@@ -359,3 +376,4 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
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`
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
type: ADR
|
||||
id: "0018"
|
||||
title: "CodeScene code health gates in CI and git hooks"
|
||||
status: active
|
||||
status: superseded
|
||||
date: 2026-03-13
|
||||
superseded_by: "0064"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
50
docs/adr/0056-system-git-cli-auth-no-provider-oauth.md
Normal file
50
docs/adr/0056-system-git-cli-auth-no-provider-oauth.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0056"
|
||||
title: "System git auth only — no provider-specific OAuth or repo APIs"
|
||||
status: active
|
||||
date: 2026-04-12
|
||||
supersedes: "0019"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria already uses the system `git` executable for the core remote workflow: commit, pull, push, status, history, and conflict resolution. The only provider-specific part left was GitHub authentication and repository management:
|
||||
|
||||
- GitHub Device Flow OAuth
|
||||
- persisted `github_token` / `github_username` settings
|
||||
- GitHub-only clone/create UI
|
||||
- GitHub API calls for repo listing and creation
|
||||
|
||||
That split made the product more complex than the actual user need. Tolaria's remote-sync users are developers who typically already have git configured via SSH keys, Git Credential Manager, Keychain helpers, or `gh auth`. The app was carrying a provider-specific auth stack even though the real transport path was already plain git CLI.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria does not implement provider-specific authentication or remote-repository APIs. All remote auth is delegated to the user's existing system git configuration, and cloning is a generic "paste any git URL" flow.**
|
||||
|
||||
Concretely:
|
||||
|
||||
- remove GitHub Device Flow commands and UI
|
||||
- remove persisted GitHub auth fields from app settings
|
||||
- remove GitHub repo list/create API integration
|
||||
- keep `clone_repo`, but make it a generic system-git clone command
|
||||
- keep commit / pull / push behavior unchanged apart from surfacing raw git errors directly
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A — Keep GitHub Device Flow OAuth** (ADR-0019, now superseded): polished GitHub-specific onboarding, but it preserves provider lock-in, token storage, and an entire second auth model beside system git.
|
||||
- **Option B — Replace OAuth with manual PAT entry**: smaller implementation than Device Flow, but still provider-specific, still stores credentials in app settings, and still teaches users the wrong abstraction.
|
||||
- **Option C — Pure system git auth** (chosen): one auth path, less code, works with any git host, and aligns the clone flow with the rest of Tolaria's git stack. Downside: users must already have git auth configured outside the app.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `CloneVaultModal` accepts any git URL and local destination path.
|
||||
- `clone_repo` shells out to system git without injecting provider tokens.
|
||||
- `git_push` / `git_pull` continue to rely on the same external git configuration; auth failures surface as raw git stderr.
|
||||
- `SettingsPanel` no longer contains a GitHub connection section.
|
||||
- Tolaria no longer stores git-provider credentials in `settings.json`.
|
||||
- GitHub, GitLab, Bitbucket, Gitea, and self-hosted remotes all work through the same product path.
|
||||
- Creating or listing remote repos from inside Tolaria is no longer supported; remote setup happens in the user's normal git tools.
|
||||
- The Getting Started vault still clones from a public remote URL, but it now goes through the same generic git clone path as every other vault import.
|
||||
|
||||
Re-evaluate if Tolaria later targets less technical users who cannot reasonably be expected to configure git outside the app.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0057"
|
||||
title: "Alpha/stable release channels with PostHog beta cohorts"
|
||||
status: active
|
||||
date: 2026-04-12
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's updater and release docs still described a canary branch, a beta updater channel, and a single `latest.json` feed. That no longer matched the desired product model:
|
||||
|
||||
- `main` should continuously publish **alpha** builds.
|
||||
- **Stable** should be promoted manually by pushing `stable-vX.Y.Z` tags.
|
||||
- "Beta" users should be modeled in PostHog for targeting and analysis, not as a separate binary or updater feed.
|
||||
|
||||
The updater also needed semver-safe versioning when a user switches between Stable and Alpha. A date-based alpha version below the latest stable release would cause the updater to ignore newer alpha builds after a stable promotion.
|
||||
|
||||
This ADR supersedes ADR-0017's canary-branch updater model.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria exposes exactly two updater channels: `stable` and `alpha`. Stable is the default feed, while every push to `main` publishes a prerelease alpha build to `alpha/latest.json`, and manually promoted `stable-vX.Y.Z` tags publish stable builds to `stable/latest.json`. Beta audiences are handled in PostHog and are not a third updater channel.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Two updater channels (`stable`, `alpha`) plus PostHog beta cohorts. Pros: matches the product requirement, keeps CI simple, keeps updater semantics understandable, and separates release distribution from experimentation audiences. Cons: requires semver-aware alpha versioning and a small migration for legacy channel settings.
|
||||
- **Option B**: Keep the canary branch / canary channel model. Pros: no workflow redesign. Cons: no longer matches how releases are actually promoted and forces distribution strategy to depend on a long-lived branch.
|
||||
- **Option C**: Add a third updater channel for beta builds. Pros: direct binary segmentation. Cons: extra CI complexity, extra updater endpoints, and unnecessary duplication because beta targeting is already better handled by PostHog.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `release.yml` now publishes alpha prereleases from every push to `main`.
|
||||
- `release-stable.yml` publishes stable releases only from `stable-v*` tags.
|
||||
- `src-tauri/src/app_updater.rs` selects `alpha/latest.json` or `stable/latest.json` at runtime.
|
||||
- `release_channel` stays an app setting, but only `alpha` is stored explicitly; Stable serializes to the default `null` value.
|
||||
- Legacy or invalid persisted channel values fall back to Stable.
|
||||
- Alpha versions are prereleases of the next stable patch version (for example `1.2.4-alpha.202604122135.7` after stable `1.2.3`) so semver ordering remains valid across channel switches.
|
||||
- The legacy GitHub Pages aliases `latest.json` and `latest-canary.json` continue to mirror alpha for backward compatibility.
|
||||
- Beta rollouts and internal-user targeting are done in PostHog using person properties or cohorts rather than updater manifests.
|
||||
|
||||
## Advice
|
||||
|
||||
If a future release process needs more than two binary distribution rings, re-evaluate this decision only when PostHog cohorting is no longer sufficient and the extra operational cost of another updater feed is justified.
|
||||
36
docs/adr/0058-claude-code-first-launch-onboarding-gate.md
Normal file
36
docs/adr/0058-claude-code-first-launch-onboarding-gate.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0058"
|
||||
title: "Claude Code first-launch onboarding gate"
|
||||
status: superseded
|
||||
superseded_by: "0062"
|
||||
date: 2026-04-12
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's AI features depend on the `claude` CLI being installed on the user's machine. New users arriving with no prior context could open the app, try AI-powered workflows, and get silent failures with no explanation.
|
||||
|
||||
A dedicated first-launch prompt was needed to:
|
||||
- Surface whether the `claude` CLI is already present.
|
||||
- Guide users to the install page if it is missing.
|
||||
- Not block experienced users who want to skip the check.
|
||||
|
||||
The existing `useOnboarding` hook already handles vault setup and resolves to a `ready` state, but it had no mechanism for a post-vault, pre-app step.
|
||||
|
||||
## Decision
|
||||
|
||||
**A one-time `ClaudeCodeOnboardingPrompt` is shown immediately after vault onboarding resolves to `ready`, before the main app shell renders. Dismissal is persisted in `localStorage` via `useClaudeCodeOnboarding`, so the gate appears exactly once per install.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Full-screen gate after vault onboarding, dismissed once and persisted in `localStorage`. Pros: cannot be missed on first launch, reuses `useClaudeCodeStatus` for live detection, zero impact on returning users. Cons: adds one extra render phase to the boot sequence.
|
||||
- **Option B**: Inline banner inside the main app. Pros: less intrusive. Cons: easy to ignore, harder to surface install link prominently.
|
||||
- **Option C**: Check at feature use time (show error when AI action fails). Pros: no new screen. Cons: poor UX — silent failure or cryptic error at the moment the user needs AI.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The app boot sequence now has four phases: loading → welcome (if needed) → Claude Code check (once) → main shell.
|
||||
- `useClaudeCodeOnboarding(enabled)` takes a boolean so the gate is skipped entirely in note windows and before vault onboarding completes.
|
||||
- The dismissal key (`tolaria:claude-code-onboarding-dismissed`) must be pre-set in Playwright storage state so smoke tests bypass the gate.
|
||||
- Re-evaluation warranted if the `claude` CLI gains an in-app auto-install path, making the manual prompt unnecessary.
|
||||
33
docs/adr/0059-local-only-git-commits-without-remote.md
Normal file
33
docs/adr/0059-local-only-git-commits-without-remote.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0059"
|
||||
title: "Local-only git commits for vaults without a remote"
|
||||
status: active
|
||||
date: 2026-04-12
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0034 mandates a git repo for every vault, but never required a remote. In practice, the commit flow always attempted a `git push` after staging and committing. Users with purely local vaults (no remote configured) would hit a push error on every commit.
|
||||
|
||||
The fix required distinguishing between two commit modes at the point of user action:
|
||||
- **Push mode**: repo has a remote → commit then push (existing behavior).
|
||||
- **Local mode**: repo has no remote → commit only, no push attempted.
|
||||
|
||||
## Decision
|
||||
|
||||
**`useCommitFlow` detects the vault's remote status before opening the commit dialog and at commit time. When `hasRemote === false`, it commits locally and skips the push step entirely, showing "Committed locally (no remote configured)" as the confirmation toast.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Runtime detection via a new `useGitRemoteStatus` hook + `CommitMode` type (`push` | `local`). Pros: transparent to the user, no configuration needed, adapts if a remote is added later. Cons: adds an async remote-status check to the commit open flow.
|
||||
- **Option B**: Require all vaults to have a remote (keep blocking behavior). Pros: simpler model. Cons: breaks the valid use case of a local-only knowledge base; contradicts ADR-0056 which removed provider-specific OAuth.
|
||||
- **Option C**: Let the push fail silently and always show success. Pros: no new logic. Cons: misleading feedback; users wouldn't know the push was skipped vs. succeeded.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `useGitRemoteStatus` is a new hook that exposes `remoteStatus` and `refreshRemoteStatus`; it is called both when opening the commit dialog and after each commit.
|
||||
- `CommitDialog` now receives a `commitMode` prop and adjusts its CTA label accordingly (`Commit & Push` vs `Commit`).
|
||||
- The `commitAndPush` callback in `CommitFlowConfig` is replaced by `resolveRemoteStatus` + `vaultPath`; the actual git operations (`git_commit`, `git_push`) are invoked directly inside `useCommitFlow`.
|
||||
- Local-only commits fire `trackEvent('commit_made')` the same as push commits for analytics continuity.
|
||||
- Re-evaluation warranted if a remote is later added to a previously-local vault and the UX should prompt the user to push accumulated commits.
|
||||
28
docs/adr/0060-network-aware-ui-gating-for-remote-features.md
Normal file
28
docs/adr/0060-network-aware-ui-gating-for-remote-features.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0060"
|
||||
title: "Network-aware UI gating for remote-dependent features"
|
||||
status: active
|
||||
date: 2026-04-13
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Some app features require an active internet connection (e.g., cloning the Getting Started vault template from GitHub). Prior to this decision, the UI would attempt the operation and surface a generic error only after failure. Users on first launch in offline environments got a confusing error when trying to use the template.
|
||||
|
||||
## Decision
|
||||
|
||||
**Introduce a `useNetworkStatus` hook that tracks `navigator.onLine` via `online`/`offline` DOM events, and use it to proactively gate UI surfaces that require a network.** Features that depend on remote access (clone, sync) show an explanatory message and disable their action button when the device is offline, rather than failing silently at execution time.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): `useNetworkStatus` hook + proactive UI disable — disables the action before the user tries it, with inline copy explaining the offline state.
|
||||
- **Option B**: Attempt and catch — let the operation run and surface the error in a toast. Simpler, but poor UX for first-launch users who don't know what went wrong.
|
||||
- **Option C**: Check connectivity with a ping on demand — more accurate but adds latency and complexity; `navigator.onLine` is sufficient for the use case.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: cleaner first-run experience for offline users; no misleading error messages.
|
||||
- Positive: `useNetworkStatus` is a reusable hook for future remote-gated features.
|
||||
- Negative: `navigator.onLine` can return `true` on a captive-portal / no-internet network — the hook reflects OS-level connectivity, not end-to-end reachability. The operation may still fail with a network error, which must still be handled.
|
||||
- Re-evaluate if the app adds more remote features that need finer-grained reachability checks.
|
||||
29
docs/adr/0061-ai-prompt-bridge-event-bus.md
Normal file
29
docs/adr/0061-ai-prompt-bridge-event-bus.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0061"
|
||||
title: "AI prompt bridge — module-level event bus for cross-component prompt routing"
|
||||
status: active
|
||||
date: 2026-04-13
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The AI panel is a sibling subtree to the command palette in the component tree. When the user submits a prompt from the command palette's AI mode, the AI panel (mounted elsewhere) needs to receive it and start processing. Props-down / callbacks-up wiring between the two would require threading state through multiple layers of unrelated components.
|
||||
|
||||
## Decision
|
||||
|
||||
**Introduce `aiPromptBridge.ts` as a module-level singleton event bus.** The bridge exposes `queueAiPrompt(text, references)` (write path) and `takeQueuedAiPrompt()` (consume path), backed by a module variable and a `CustomEvent` on `window` (`tolaria:ai-prompt-queued`). The command palette enqueues a prompt; the AI panel listens for the event, consumes the prompt via `takeQueuedAiPrompt`, and dispatches it to the agent. A companion `requestOpenAiChat()` function fires a separate `tolaria:open-ai-chat` event to open the panel before the prompt is sent.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): module-level singleton + `window` events — zero dependencies, no new global state manager, consistent with the existing `window.dispatchEvent` pattern already used for menu-command bridging.
|
||||
- **Option B**: Lift AI panel state to a shared ancestor (e.g., `App.tsx`) and pass `onPrompt` callback down — would require `App.tsx` to own AI agent state, bloating it further; conflicts with ADR-0026 (props-down principle).
|
||||
- **Option C**: Zustand / Jotai global store atom — adds a dependency and architecture overhead for a narrow, two-participant channel.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: decouples command palette from AI panel with no shared ancestor coupling.
|
||||
- Positive: any future surface (e.g., wikilink context menu, note action bar) can call `queueAiPrompt` without tree-level wiring.
|
||||
- Negative: module-level mutable state is harder to test in isolation; tests must call `takeQueuedAiPrompt` to drain state between runs.
|
||||
- Negative: the event is fire-and-forget — if the AI panel is not mounted when the event fires, the prompt is silently dropped (currently not an issue as the panel is always mounted).
|
||||
- Re-evaluate if the number of AI entry points grows large enough to warrant a proper state management solution.
|
||||
32
docs/adr/0062-selectable-cli-ai-agents.md
Normal file
32
docs/adr/0062-selectable-cli-ai-agents.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0062"
|
||||
title: "Selectable CLI AI agents with a shared panel architecture"
|
||||
status: active
|
||||
date: 2026-04-13
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's AI panel, onboarding flow, and status surfaces were built around a single CLI dependency: Claude Code. That worked for the first release, but it made every UI and backend seam agent-specific. Adding Codex as a second supported CLI agent would have duplicated large parts of the app: separate availability checks, a second onboarding path, another status badge, and yet another streaming hook.
|
||||
|
||||
The product direction is broader than a single vendor. Tolaria needs one AI panel that can target multiple local CLI agents while preserving the same MCP-backed vault tooling, the same note-context assembly, and a single install-local preference for which agent should be used by default.
|
||||
|
||||
## Decision
|
||||
|
||||
**Introduce a shared CLI-agent abstraction for Tolaria's AI surfaces.** The frontend now treats agents as a small registry (`claude_code`, `codex`) with labels, install URLs, availability state, and a persisted `default_ai_agent` setting. The AI panel, onboarding gate, command palette, and status bar all read from that shared model. On the backend, `ai_agents.rs` owns agent detection and streaming, dispatching to per-agent adapters: Claude still flows through `claude_cli.rs`, while Codex is launched through `codex exec --json` with Tolaria's MCP server injected via transient config flags.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): shared agent registry + backend adapter layer — one panel, one preference, one onboarding path, and a clear place to add future CLI agents.
|
||||
- **Option B**: keep the UI Claude-specific and bolt on Codex as a second special case — lowest short-term cost, but every new agent multiplies the number of bespoke checks, prompts, and command handlers.
|
||||
- **Option C**: split the product into separate per-agent panels — clearer ownership per integration, but fragments the UX and makes command-palette / status-bar interactions inconsistent.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: new CLI agents can be added by implementing one backend adapter and registering one frontend definition.
|
||||
- Positive: onboarding and settings now explain the AI capability of the app at the product level rather than assuming Claude Code is the only valid path.
|
||||
- Positive: the default agent is installation-local, matching ADR-0004's rule that machine-specific tool preferences belong in app settings rather than the vault.
|
||||
- Negative: event normalization is now Tolaria-owned; backend adapters must translate each CLI's stream format into a common event model.
|
||||
- Negative: some user guidance becomes agent-specific again at the edge, such as install links and authentication errors (`claude` login vs `codex login`).
|
||||
- Re-evaluate if one agent needs capabilities the shared panel cannot express cleanly, or if Tolaria ever moves from CLI subprocesses to a dedicated local SDK/runtime.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0063"
|
||||
title: "BlockNote code-block package for editor syntax highlighting"
|
||||
status: active
|
||||
date: 2026-04-13
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria uses BlockNote for rich-text editing. Fenced code blocks already render on BlockNote's dark `pre > code` surface, but they were missing syntax highlighting and inherited the muted inline-code chip background from the global `code` selector in `EditorTheme.css`. The QA expectation is a dark code block with highlighted tokens and light code text, without regressing inline-code styling elsewhere in the editor.
|
||||
|
||||
BlockNote documents syntax highlighting as a schema concern: replace the default `codeBlock` spec with `createCodeBlockSpec(...)` and provide a Shiki highlighter. Tolaria also needs to preserve the existing default behavior for unlabeled code blocks, which should stay plain text instead of defaulting to JavaScript.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria overrides the default BlockNote `codeBlock` spec with `@blocknote/code-block`, keeps `defaultLanguage: "text"`, and scopes the muted inline-code chip styling away from fenced code blocks.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Use `@blocknote/code-block`** (chosen): first-party BlockNote path, ships supported language aliases and a bundled Shiki highlighter, renders `.shiki` token spans in-editor, and avoids maintaining a parallel ProseMirror plugin integration.
|
||||
- **Use a custom `createCodeBlockSpec({ createHighlighter })` bundle**: also valid, but Tolaria does not need a custom language/theme bundle beyond BlockNote's packaged setup right now.
|
||||
- **Keep BlockNote defaults and only fix CSS**: removes the nested gray chip bug, but leaves fenced code blocks unhighlighted and fails the product requirement.
|
||||
|
||||
## Consequences
|
||||
|
||||
Tolaria's highlighting now lives in the editor schema instead of an editor-side plugin hook. `src/components/editorSchema.tsx` swaps in `createCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: "text" })`, which adds BlockNote's language selector plus Shiki token spans for supported fenced blocks. `EditorTheme.css` continues to keep the `pre > code` background transparent so BlockNote's dark code-block shell remains intact.
|
||||
|
||||
The tradeoff is one new first-party dependency and BlockNote's bundled language menu inside code blocks. If Tolaria later needs a narrower bundle, custom themes, or export-time highlighting parity, this ADR should be superseded with a custom Shiki bundle decision.
|
||||
30
docs/adr/0064-ratcheted-codescene-thresholds.md
Normal file
30
docs/adr/0064-ratcheted-codescene-thresholds.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0064"
|
||||
title: "Ratcheted CodeScene thresholds as the quality gate baseline"
|
||||
status: active
|
||||
date: 2026-04-14
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0018 established CodeScene code-health gates so Tolaria could block regressions before code reached `main`. Since then, the codebase has improved materially and the tracked baseline in `.codescene-thresholds` has been ratcheted above the original 9.50 / 9.31 minimums.
|
||||
|
||||
Leaving ADR-0018 active would make the architecture record stale: the enforced thresholds are now stricter than the decision document says, and the current workflow intentionally tightens them as the project's sustained health improves.
|
||||
|
||||
## Decision
|
||||
|
||||
**Supersede ADR-0018 and treat `.codescene-thresholds` as the ratcheted policy baseline for Tolaria's CodeScene gate.** The current required minimums are `HOTSPOT_THRESHOLD=9.84` and `AVERAGE_THRESHOLD=9.45`. Thresholds move upward only when the repository can sustain a stricter baseline without immediately regressing.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Ratchet the enforced thresholds and document the new baseline** (chosen): keeps the ADRs aligned with the real gate, preserves the Boy Scout Rule, and makes code-health expectations stricter as the codebase improves.
|
||||
- **Keep ADR-0018 active and treat higher thresholds as an implementation detail**: lower documentation churn, but the active ADR would no longer describe the actual CI and hook policy.
|
||||
- **Remove numeric thresholds from ADRs entirely**: more durable on paper, but loses the explicit quality bar that developers are expected to maintain.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `.codescene-thresholds` is now the authoritative location for the current numeric gate values.
|
||||
- ADRs must be superseded again if Tolaria makes another meaningful policy jump in CodeScene thresholds.
|
||||
- Pre-push and related quality checks now enforce a stricter floor than ADR-0018 described.
|
||||
- The quality gate remains intentionally one-way: relaxing thresholds would require an explicit architectural reversal, not a quiet config edit.
|
||||
30
docs/adr/0065-root-managed-ai-guidance-files.md
Normal file
30
docs/adr/0065-root-managed-ai-guidance-files.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0065"
|
||||
title: "Root-managed AI guidance files with Claude shim"
|
||||
status: active
|
||||
date: 2026-04-14
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria now supports multiple local CLI agents, but vault-level guidance still carried legacy assumptions. Existing vault bootstrap and repair flows centered on `config/agents.md`, while modern coding agents expect instructions at the vault root. That mismatch made managed guidance harder to reason about, left Claude Code compatibility implicit, and gave the UI no reliable way to distinguish between Tolaria-managed files that can be repaired and user-authored custom guidance that must be preserved.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria manages vault AI guidance at the vault root.** `AGENTS.md` is the canonical shared guidance file, `CLAUDE.md` is a compatibility shim that points Claude Code back to `AGENTS.md`, and Tolaria classifies both files as `managed`, `missing`, `broken`, or `custom` so repair flows restore only Tolaria-managed guidance without overwriting custom user files.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Root `AGENTS.md` as canonical plus a root `CLAUDE.md` shim** (chosen): matches current agent expectations, keeps one source of truth for shared instructions, and makes repair status explicit.
|
||||
- **Keep managed guidance under `config/agents.md`**: preserves the older structure, but hides a user-facing integration contract behind legacy config paths and keeps Claude compatibility indirect.
|
||||
- **Maintain separate full instruction files for each agent**: simple per tool, but duplicates instructions and increases drift risk whenever guidance changes.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New and repaired vaults now seed `AGENTS.md` and `CLAUDE.md` at the vault root.
|
||||
- Legacy `config/agents.md` content is migrated forward when safe, then the obsolete file is removed.
|
||||
- The status bar and command palette can expose a first-class restore action because backend guidance state is normalized.
|
||||
- Custom root guidance files are preserved instead of being silently overwritten by repair flows.
|
||||
- Tolaria keeps a single shared guidance document even while supporting multiple CLI agents.
|
||||
- Re-evaluate if supported agents stop relying on root-level files or if future agent integrations require materially different vault instructions instead of a shared source of truth.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0066"
|
||||
title: "Calendar-semver versioning for alpha and stable releases"
|
||||
status: active
|
||||
date: 2026-04-16
|
||||
supersedes: "0057"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0057 kept Tolaria on two updater channels and used "next stable patch" semver for alpha builds. That preserved ordering, but it no longer matched the agreed product naming:
|
||||
|
||||
- Alpha should display as `Alpha YYYY.M.D.N`
|
||||
- Alpha should ship the technical version `YYYY.M.D-alpha.N`
|
||||
- Stable should ship and display as `YYYY.M.D`
|
||||
|
||||
The naming change still needs to stay semver-safe when users switch between Stable and Alpha. A pure same-day calendar alpha would become older than a same-day stable promotion, so the workflow needs a monotonicity guard in addition to cleaner display strings.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria keeps exactly two updater channels (`stable` and `alpha`), but both now use calendar-semver release numbers.** Stable promotions use `stable-vYYYY.M.D` tags and stamp the technical version `YYYY.M.D`. Every push to `main` publishes an alpha build with technical version `YYYY.M.D-alpha.N` and display label `Alpha YYYY.M.D.N`.
|
||||
|
||||
If the latest stable tag already uses the current UTC calendar date, the alpha workflow advances to the next calendar day before assigning `-alpha.N`. That keeps alpha semver-newer than the most recent stable build even after a same-day promotion.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Calendar semver with a next-day safeguard** (chosen): matches the agreed naming, keeps user-facing labels clean, and preserves updater ordering across channel switches.
|
||||
- **Calendar semver without a safeguard**: simplest display model, but alpha can become semver-older than Stable after a same-day promotion.
|
||||
- **Keep ADR-0057's next-patch prerelease numbering**: semver-safe, but it does not match the agreed release naming or the product surfaces that should show calendar-based versions.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Release workflows now compute both a technical version and a display version.
|
||||
- User-facing version surfaces strip technical prerelease noise into clean labels (`Alpha YYYY.M.D.N` or `YYYY.M.D`).
|
||||
- Stable promotions must use `stable-vYYYY.M.D` tags instead of patch-based semver tags.
|
||||
- Alpha sequence numbers are scoped to a calendar core date and remain compatible with the updater manifests.
|
||||
@@ -72,9 +72,9 @@ proposed → active → superseded
|
||||
| [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 |
|
||||
| [0017](0017-canary-release-channel.md) | Canary release channel and feature flags | active |
|
||||
| [0018](0018-codescene-code-health-gates.md) | CodeScene code health gates in CI | active |
|
||||
| [0019](0019-github-device-flow-oauth.md) | GitHub device flow OAuth for vault sync | active |
|
||||
| [0017](canary-release-channel-and-local-feature-flags.md) | Canary release channel and feature flags | superseded → [0057](0057-alpha-stable-release-channels-and-beta-cohorts.md) |
|
||||
| [0018](0018-codescene-code-health-gates.md) | CodeScene code health gates in CI | superseded → [0064](0064-ratcheted-codescene-thresholds.md) |
|
||||
| [0019](0019-github-device-flow-oauth.md) | GitHub device flow OAuth for vault sync | superseded → [0056](0056-system-git-cli-auth-no-provider-oauth.md) |
|
||||
| [0020](0020-keyboard-first-design.md) | Keyboard-first design principle | active |
|
||||
| [0021](0021-push-to-main-workflow.md) | Push directly to main (no PRs) | active |
|
||||
| [0022](0022-blocknote-rich-text-editor.md) | BlockNote as the rich text editor | active |
|
||||
@@ -111,3 +111,13 @@ proposed → active → superseded
|
||||
| [0053](0053-webview-init-prevention-for-browser-reserved-shortcuts.md) | Webview-init prevention for browser-reserved shortcuts | active |
|
||||
| [0054](0054-deterministic-shortcut-qa-matrix.md) | Deterministic shortcut QA matrix | active |
|
||||
| [0055](0055-h1-is-the-only-editor-title-surface.md) | H1 is the only editor title surface | active |
|
||||
| [0056](0056-system-git-cli-auth-no-provider-oauth.md) | System git auth only — no provider-specific OAuth or repo APIs | active |
|
||||
| [0057](0057-alpha-stable-release-channels-and-beta-cohorts.md) | Alpha/stable release channels with PostHog beta cohorts | active |
|
||||
| [0058](0058-claude-code-first-launch-onboarding-gate.md) | Claude Code first-launch onboarding gate | superseded → [0062](0062-selectable-cli-ai-agents.md) |
|
||||
| [0059](0059-local-only-git-commits-without-remote.md) | Local-only git commits for vaults without a remote | active |
|
||||
| [0060](0060-network-aware-ui-gating-for-remote-features.md) | Network-aware UI gating for remote-dependent features | active |
|
||||
| [0061](0061-ai-prompt-bridge-event-bus.md) | AI prompt bridge — module-level event bus for cross-component prompt routing | active |
|
||||
| [0062](0062-selectable-cli-ai-agents.md) | Selectable CLI AI agents with a shared panel architecture | active |
|
||||
| [0063](0063-blocknote-code-block-package-for-editor-highlighting.md) | BlockNote code-block package for editor syntax highlighting | active |
|
||||
| [0064](0064-ratcheted-codescene-thresholds.md) | Ratcheted CodeScene thresholds as the quality gate baseline | active |
|
||||
| [0065](0065-root-managed-ai-guidance-files.md) | Root-managed AI guidance files with Claude shim | active |
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"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/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/wikilink-path-fix.spec.ts",
|
||||
"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/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/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@blocknote/code-block": "^0.46.2",
|
||||
"@blocknote/core": "^0.46.2",
|
||||
"@blocknote/mantine": "^0.46.2",
|
||||
"@blocknote/react": "^0.46.2",
|
||||
@@ -54,7 +55,6 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"katex": "^0.16.28",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.564.0",
|
||||
"posthog-js": "^1.363.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
||||
60
patches/@blocknote__react@0.46.2.patch
Normal file
60
patches/@blocknote__react@0.46.2.patch
Normal file
@@ -0,0 +1,60 @@
|
||||
diff --git a/dist/blocknote-react.js b/dist/blocknote-react.js
|
||||
index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3f9f0ea59 100644
|
||||
--- a/dist/blocknote-react.js
|
||||
+++ b/dist/blocknote-react.js
|
||||
@@ -157,6 +157,16 @@ function so(e) {
|
||||
const n = "getBoundingClientRect" in e ? () => e.getBoundingClientRect() : () => e.element.getBoundingClientRect();
|
||||
return () => "element" in e && (e.cacheMountedBoundingClientRect ?? !0) ? (e.element.isConnected && (t = n()), t) : n();
|
||||
}
|
||||
+function __bnSafeDomAtPos(e, t) {
|
||||
+ const n = e.prosemirrorView;
|
||||
+ if (!n || n.isDestroyed)
|
||||
+ return null;
|
||||
+ try {
|
||||
+ return n.domAtPos(t);
|
||||
+ } catch {
|
||||
+ return null;
|
||||
+ }
|
||||
+}
|
||||
const z = (e) => {
|
||||
var h, b, p;
|
||||
const { refs: t, floatingStyles: n, context: o } = Ze({
|
||||
@@ -216,9 +226,7 @@ const Lt = (e) => {
|
||||
const s = Ue(t, c.doc);
|
||||
if (!s)
|
||||
return;
|
||||
- const { node: a } = r.prosemirrorView.domAtPos(
|
||||
- s.posBeforeNode + 1
|
||||
- );
|
||||
+ const a = __bnSafeDomAtPos(r, s.posBeforeNode + 1)?.node;
|
||||
if (a instanceof Element)
|
||||
return {
|
||||
element: a
|
||||
@@ -3306,14 +3314,15 @@ const pi = (e) => {
|
||||
);
|
||||
if (!m)
|
||||
return {};
|
||||
- const g = m.posBeforeNode + 1, f = t.prosemirrorView.domAtPos(
|
||||
+ const g = m.posBeforeNode + 1, f = __bnSafeDomAtPos(
|
||||
+ t,
|
||||
g + 1
|
||||
- ).node;
|
||||
+ )?.node;
|
||||
if (!(f instanceof Element))
|
||||
return {};
|
||||
if (d.tableReference = { element: f }, r.rowIndex === void 0 || r.colIndex === void 0)
|
||||
return d;
|
||||
- const h = t.prosemirrorState.doc.resolve(g + 1).posAtIndex(r.rowIndex), b = t.prosemirrorState.doc.resolve(h + 1).posAtIndex(r.colIndex), p = t.prosemirrorView.domAtPos(b + 1).node;
|
||||
+ const h = t.prosemirrorState.doc.resolve(g + 1).posAtIndex(r.rowIndex), b = t.prosemirrorState.doc.resolve(h + 1).posAtIndex(r.colIndex), p = __bnSafeDomAtPos(t, b + 1)?.node;
|
||||
return p instanceof Element ? (d.cellReference = { element: p }, d.rowReference = {
|
||||
element: f,
|
||||
getBoundingClientRect: () => {
|
||||
@@ -4371,7 +4380,7 @@ const El = (e) => {
|
||||
const a = Ue(t, c.prosemirrorState.doc);
|
||||
if (!a)
|
||||
return;
|
||||
- const u = a.posBeforeNode + 1, d = c.prosemirrorState.doc.resolve(u + 1).posAtIndex(o || 0), m = c.prosemirrorState.doc.resolve(d + 1).posAtIndex(n || 0), { node: g } = c.prosemirrorView.domAtPos(m + 1);
|
||||
+ const u = a.posBeforeNode + 1, d = c.prosemirrorState.doc.resolve(u + 1).posAtIndex(o || 0), m = c.prosemirrorState.doc.resolve(d + 1).posAtIndex(n || 0), g = __bnSafeDomAtPos(c, m + 1)?.node;
|
||||
if (g instanceof Element)
|
||||
return g;
|
||||
}, [c, t, n, o]);
|
||||
@@ -1,18 +1,32 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
const baseURL = process.env.BASE_URL || 'http://localhost:5201'
|
||||
const claudeCodeOnboardingStorageState = {
|
||||
cookies: [],
|
||||
origins: [
|
||||
{
|
||||
origin: baseURL,
|
||||
localStorage: [
|
||||
{ name: 'tolaria:claude-code-onboarding-dismissed', value: '1' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/smoke',
|
||||
timeout: 20_000,
|
||||
retries: 2,
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:5201',
|
||||
baseURL,
|
||||
headless: true,
|
||||
storageState: claudeCodeOnboardingStorageState,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
|
||||
webServer: {
|
||||
command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5201'}`,
|
||||
url: process.env.BASE_URL || 'http://localhost:5201',
|
||||
url: baseURL,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,6 +3,17 @@ import { defineConfig } from '@playwright/test'
|
||||
const baseURL = process.env.BASE_URL || 'http://127.0.0.1:41741'
|
||||
const port = new URL(baseURL).port || '41741'
|
||||
const reuseExistingServer = process.env.PLAYWRIGHT_REUSE_SERVER === '1'
|
||||
const claudeCodeOnboardingStorageState = {
|
||||
cookies: [],
|
||||
origins: [
|
||||
{
|
||||
origin: baseURL,
|
||||
localStorage: [
|
||||
{ name: 'tolaria:claude-code-onboarding-dismissed', value: '1' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
@@ -13,6 +24,7 @@ export default defineConfig({
|
||||
use: {
|
||||
baseURL,
|
||||
headless: true,
|
||||
storageState: claudeCodeOnboardingStorageState,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
|
||||
webServer: {
|
||||
|
||||
113
pnpm-lock.yaml
generated
113
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ patchedDependencies:
|
||||
'@blocknote/core@0.46.2':
|
||||
hash: 9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec
|
||||
path: patches/@blocknote__core@0.46.2.patch
|
||||
'@blocknote/react@0.46.2':
|
||||
hash: e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97
|
||||
path: patches/@blocknote__react@0.46.2.patch
|
||||
prosemirror-tables@1.8.5:
|
||||
hash: cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b
|
||||
path: patches/prosemirror-tables@1.8.5.patch
|
||||
@@ -19,6 +22,9 @@ importers:
|
||||
'@anthropic-ai/sdk':
|
||||
specifier: ^0.78.0
|
||||
version: 0.78.0(zod@4.3.6)
|
||||
'@blocknote/code-block':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
|
||||
'@blocknote/core':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
@@ -27,7 +33,7 @@ importers:
|
||||
version: 0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@blocknote/react':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
version: 0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@codemirror/commands':
|
||||
specifier: ^6.10.2
|
||||
version: 6.10.2
|
||||
@@ -118,9 +124,6 @@ importers:
|
||||
katex:
|
||||
specifier: ^0.16.28
|
||||
version: 0.16.28
|
||||
lowlight:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
lucide-react:
|
||||
specifier: ^0.564.0
|
||||
version: 0.564.0(react@19.2.4)
|
||||
@@ -366,6 +369,11 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@blocknote/code-block@0.46.2':
|
||||
resolution: {integrity: sha512-/jLxECkbJjIx4K0zR0uVVz5AKt/08YGEseYuDun6ACHvW7027YRQIqHThXfMwZuRujijraoPHI3gbLXCaaRRRQ==}
|
||||
peerDependencies:
|
||||
'@blocknote/core': ^0.46.2
|
||||
|
||||
'@blocknote/core@0.46.2':
|
||||
resolution: {integrity: sha512-p+QFwrTQfQC08f7JoFg9uuwbAUkGhRakCXhHhY63Wd8ARS0ktHfc9eUnJderXQTEtWfHjACjZOyt2FifX6Lalw==}
|
||||
peerDependencies:
|
||||
@@ -1823,9 +1831,28 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.14.0 || 17.x || 18.x || 19.x
|
||||
|
||||
'@shikijs/core@3.23.0':
|
||||
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
|
||||
|
||||
'@shikijs/engine-javascript@3.23.0':
|
||||
resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
|
||||
|
||||
'@shikijs/langs-precompiled@3.23.0':
|
||||
resolution: {integrity: sha512-NVFZ+aosM+a90oUmlM8pRZkVZPFgMabCPicOaP0xklM7Lo6U6J9JqKs+ld1Z9K85k1mfQITc6r7VfZiifitPDA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/langs@3.23.0':
|
||||
resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
|
||||
|
||||
'@shikijs/themes@3.23.0':
|
||||
resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
|
||||
|
||||
'@shikijs/types@3.22.0':
|
||||
resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==}
|
||||
|
||||
'@shikijs/types@3.23.0':
|
||||
resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
@@ -3461,6 +3488,12 @@ packages:
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
oniguruma-parser@0.12.1:
|
||||
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
|
||||
|
||||
oniguruma-to-es@4.3.5:
|
||||
resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -3771,6 +3804,15 @@ packages:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
regex-utilities@2.3.0:
|
||||
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
|
||||
|
||||
regex@6.1.0:
|
||||
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
||||
|
||||
rehype-format@5.0.1:
|
||||
resolution: {integrity: sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==}
|
||||
|
||||
@@ -4469,6 +4511,16 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
|
||||
dependencies:
|
||||
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@shikijs/core': 3.23.0
|
||||
'@shikijs/engine-javascript': 3.23.0
|
||||
'@shikijs/langs': 3.23.0
|
||||
'@shikijs/langs-precompiled': 3.23.0
|
||||
'@shikijs/themes': 3.23.0
|
||||
'@shikijs/types': 3.22.0
|
||||
|
||||
'@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
|
||||
dependencies:
|
||||
'@emoji-mart/data': 1.2.1
|
||||
@@ -4522,7 +4574,7 @@ snapshots:
|
||||
'@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/react': 0.46.2(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@blocknote/react': 0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@mantine/core': 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@mantine/hooks': 8.3.14(react@19.2.4)
|
||||
'@mantine/utils': 6.0.22(react@19.2.4)
|
||||
@@ -4541,7 +4593,7 @@ snapshots:
|
||||
- sugar-high
|
||||
- supports-color
|
||||
|
||||
'@blocknote/react@0.46.2(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
'@blocknote/react@0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@emoji-mart/data': 1.2.1
|
||||
@@ -5989,11 +6041,42 @@ snapshots:
|
||||
'@sentry/core': 10.47.0
|
||||
react: 19.2.4
|
||||
|
||||
'@shikijs/core@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.5
|
||||
|
||||
'@shikijs/langs-precompiled@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
oniguruma-to-es: 4.3.5
|
||||
|
||||
'@shikijs/langs@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
|
||||
'@shikijs/themes@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
|
||||
'@shikijs/types@3.22.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/types@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
@@ -7915,6 +7998,14 @@ snapshots:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
oniguruma-parser@0.12.1: {}
|
||||
|
||||
oniguruma-to-es@4.3.5:
|
||||
dependencies:
|
||||
oniguruma-parser: 0.12.1
|
||||
regex: 6.1.0
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -8321,6 +8412,16 @@ snapshots:
|
||||
indent-string: 4.0.0
|
||||
strip-indent: 3.0.0
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
regex-utilities@2.3.0: {}
|
||||
|
||||
regex@6.1.0:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
rehype-format@5.0.1:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
@@ -6,4 +6,5 @@ ignoredBuiltDependencies:
|
||||
|
||||
patchedDependencies:
|
||||
'@blocknote/core@0.46.2': patches/@blocknote__core@0.46.2.patch
|
||||
'@blocknote/react@0.46.2': patches/@blocknote__react@0.46.2.patch
|
||||
prosemirror-tables@1.8.5: patches/prosemirror-tables@1.8.5.patch
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Design Diff Analyzer
|
||||
* Analyzes changes to ui-design.pen and generates implementation tasks for Claude Code
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
|
||||
function getDiff() {
|
||||
try {
|
||||
return execSync('git diff HEAD~1 ui-design.pen', { encoding: 'utf8' });
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeDiff(diff) {
|
||||
const changes = {
|
||||
colors: [],
|
||||
typography: [],
|
||||
spacing: [],
|
||||
layout: [],
|
||||
content: []
|
||||
};
|
||||
|
||||
const lines = diff.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Color changes
|
||||
if (line.includes('"fill":') || line.includes('"backgroundColor":')) {
|
||||
const oldMatch = lines[i-1]?.match(/"(fill|backgroundColor)":\s*"([^"]+)"/);
|
||||
const newMatch = line.match(/"(fill|backgroundColor)":\s*"([^"]+)"/);
|
||||
|
||||
if (oldMatch && newMatch && oldMatch[2] !== newMatch[2]) {
|
||||
changes.colors.push({
|
||||
type: oldMatch[1],
|
||||
from: oldMatch[2],
|
||||
to: newMatch[2]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Font size changes
|
||||
if (line.includes('"fontSize":')) {
|
||||
const oldMatch = lines[i-1]?.match(/"fontSize":\s*(\d+)/);
|
||||
const newMatch = line.match(/"fontSize":\s*(\d+)/);
|
||||
|
||||
if (oldMatch && newMatch && oldMatch[1] !== newMatch[1]) {
|
||||
changes.typography.push({
|
||||
type: 'fontSize',
|
||||
from: parseInt(oldMatch[1]),
|
||||
to: parseInt(newMatch[1])
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Spacing/padding changes
|
||||
if (line.includes('"padding":') || line.includes('"margin":') || line.includes('"gap":')) {
|
||||
const oldMatch = lines[i-1]?.match(/"(padding|margin|gap)":\s*(\d+)/);
|
||||
const newMatch = line.match(/"(padding|margin|gap)":\s*(\d+)/);
|
||||
|
||||
if (oldMatch && newMatch && oldMatch[2] !== newMatch[2]) {
|
||||
changes.spacing.push({
|
||||
type: oldMatch[1],
|
||||
from: parseInt(oldMatch[2]),
|
||||
to: parseInt(newMatch[2])
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Content changes (text only - no implementation needed)
|
||||
if (line.includes('"content":')) {
|
||||
const oldMatch = lines[i-1]?.match(/"content":\s*"([^"]+)"/);
|
||||
const newMatch = line.match(/"content":\s*"([^"]+)"/);
|
||||
|
||||
if (oldMatch && newMatch && oldMatch[1] !== newMatch[1]) {
|
||||
changes.content.push({
|
||||
from: oldMatch[1],
|
||||
to: newMatch[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Layout changes - new components added
|
||||
if (line.startsWith('+') && line.includes('"name":')) {
|
||||
// Look back for "type": "frame" in recent lines
|
||||
const frameTypeMatch = lines.slice(Math.max(0, i-5), i+1).find(l =>
|
||||
l.startsWith('+') && l.includes('"type": "frame"')
|
||||
);
|
||||
|
||||
if (frameTypeMatch) {
|
||||
const nameMatch = line.match(/"name":\s*"([^"]+)"/);
|
||||
if (nameMatch) {
|
||||
changes.layout.push({
|
||||
action: 'added',
|
||||
component: nameMatch[1],
|
||||
type: 'frame'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layout changes - components removed
|
||||
if (line.startsWith('-') && line.includes('"name":')) {
|
||||
const frameTypeMatch = lines.slice(Math.max(0, i-5), i+1).find(l =>
|
||||
l.startsWith('-') && l.includes('"type": "frame"')
|
||||
);
|
||||
|
||||
if (frameTypeMatch) {
|
||||
const nameMatch = line.match(/"name":\s*"([^"]+)"/);
|
||||
if (nameMatch) {
|
||||
changes.layout.push({
|
||||
action: 'removed',
|
||||
component: nameMatch[1],
|
||||
type: 'frame'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Height changes (structural)
|
||||
if (line.includes('"height":')) {
|
||||
const oldMatch = lines[i-1]?.match(/"height":\s*(\d+)/);
|
||||
const newMatch = line.match(/"height":\s*(\d+)/);
|
||||
|
||||
if (oldMatch && newMatch && oldMatch[1] !== newMatch[1]) {
|
||||
changes.layout.push({
|
||||
action: 'resized',
|
||||
property: 'height',
|
||||
from: parseInt(oldMatch[1]),
|
||||
to: parseInt(newMatch[1])
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
function analyzeIntent(changes) {
|
||||
const intent = {
|
||||
patterns: [],
|
||||
systems: [],
|
||||
recommendations: []
|
||||
};
|
||||
|
||||
// Detect color-coding system for types
|
||||
const accentColorIntro = changes.colors.filter(c =>
|
||||
c.to.includes('accent-') && !c.from.includes('accent-')
|
||||
);
|
||||
|
||||
if (accentColorIntro.length >= 4) {
|
||||
const colors = [...new Set(accentColorIntro.map(c => c.to))];
|
||||
intent.systems.push({
|
||||
type: 'color-coding',
|
||||
description: 'Type-based color system',
|
||||
colors: colors,
|
||||
explanation: 'Each entity type (Project, Procedure, Person, Topic) has its own accent color. This color should be used consistently for: icons, active states, badges, and any UI element representing that type.',
|
||||
critical: '⚠️ IMPORTANT: Active states must use the color **dynamically** based on the note type, not hardcoded. Example: if a Project is selected, use accent-red; if a Procedure is selected, use accent-purple.'
|
||||
});
|
||||
}
|
||||
|
||||
// Detect contrast improvements
|
||||
const contrastChanges = changes.colors.filter(c =>
|
||||
c.from === '$--muted-foreground' && c.to === '$--foreground'
|
||||
);
|
||||
|
||||
if (contrastChanges.length > 3) {
|
||||
intent.patterns.push({
|
||||
type: 'contrast-improvement',
|
||||
description: 'Increased text contrast for better readability',
|
||||
count: contrastChanges.length
|
||||
});
|
||||
}
|
||||
|
||||
// Detect spacing improvements
|
||||
if (changes.spacing.length > 0) {
|
||||
const avgIncrease = changes.spacing.reduce((sum, s) => sum + (s.to - s.from), 0) / changes.spacing.length;
|
||||
if (avgIncrease > 0) {
|
||||
intent.patterns.push({
|
||||
type: 'spacing-increase',
|
||||
description: 'Increased spacing for less cluttered UI',
|
||||
avgIncrease: Math.round(avgIncrease)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
function generateTasks(changes, intent) {
|
||||
const tasks = [];
|
||||
|
||||
// Color changes → update theme.json or CSS variables
|
||||
if (changes.colors.length > 0) {
|
||||
const colorList = changes.colors.map(c =>
|
||||
` - ${c.type}: ${c.from} → ${c.to}`
|
||||
).join('\n');
|
||||
|
||||
let details = `Color changes detected:\n${colorList}\n\nUpdate src/theme.json or CSS variables to match the design.`;
|
||||
|
||||
// Add intent explanation if color-coding system detected
|
||||
const colorSystem = intent.systems.find(s => s.type === 'color-coding');
|
||||
if (colorSystem) {
|
||||
details += `\n\n${colorSystem.explanation}\n\n${colorSystem.critical}`;
|
||||
}
|
||||
|
||||
tasks.push({
|
||||
priority: 'high',
|
||||
description: 'Update color palette',
|
||||
details: details
|
||||
});
|
||||
}
|
||||
|
||||
// Typography changes → update theme.json
|
||||
if (changes.typography.length > 0) {
|
||||
const typoList = changes.typography.map(t =>
|
||||
` - ${t.type}: ${t.from}px → ${t.to}px`
|
||||
).join('\n');
|
||||
|
||||
tasks.push({
|
||||
priority: 'medium',
|
||||
description: 'Update typography',
|
||||
details: `Typography changes detected:\n${typoList}\n\nUpdate src/theme.json typography settings.`
|
||||
});
|
||||
}
|
||||
|
||||
// Spacing changes → update theme.json
|
||||
if (changes.spacing.length > 0) {
|
||||
const spacingList = changes.spacing.map(s =>
|
||||
` - ${s.type}: ${s.from}px → ${s.to}px`
|
||||
).join('\n');
|
||||
|
||||
tasks.push({
|
||||
priority: 'medium',
|
||||
description: 'Update spacing',
|
||||
details: `Spacing changes detected:\n${spacingList}\n\nUpdate src/theme.json spacing values.`
|
||||
});
|
||||
}
|
||||
|
||||
// Layout changes → structural modifications
|
||||
if (changes.layout.length > 0) {
|
||||
const added = changes.layout.filter(l => l.action === 'added');
|
||||
const removed = changes.layout.filter(l => l.action === 'removed');
|
||||
const resized = changes.layout.filter(l => l.action === 'resized');
|
||||
|
||||
let layoutDetails = 'Layout changes detected:\n\n';
|
||||
|
||||
if (added.length > 0) {
|
||||
layoutDetails += 'Components added:\n';
|
||||
added.forEach(c => layoutDetails += ` + ${c.component} (${c.type})\n`);
|
||||
layoutDetails += '\n';
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
layoutDetails += 'Components removed:\n';
|
||||
removed.forEach(c => layoutDetails += ` - ${c.component} (${c.type})\n`);
|
||||
layoutDetails += '\n';
|
||||
}
|
||||
|
||||
if (resized.length > 0) {
|
||||
layoutDetails += 'Components resized:\n';
|
||||
resized.forEach(c => layoutDetails += ` • ${c.property}: ${c.from}px → ${c.to}px\n`);
|
||||
layoutDetails += '\n';
|
||||
}
|
||||
|
||||
layoutDetails += 'Review ui-design.pen diff and update components accordingly.';
|
||||
|
||||
tasks.push({
|
||||
priority: 'high',
|
||||
description: 'Update layout structure',
|
||||
details: layoutDetails
|
||||
});
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
function formatOutput(tasks, changes, intent) {
|
||||
if (tasks.length === 0 && changes.content.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let output = '🎨 Design changes detected:\n\n';
|
||||
|
||||
// Add design intent analysis
|
||||
if (intent.systems.length > 0 || intent.patterns.length > 0) {
|
||||
output += '🧠 Design Intent Analysis:\n\n';
|
||||
|
||||
intent.systems.forEach(system => {
|
||||
output += `📐 ${system.description}\n`;
|
||||
output += `${system.explanation}\n`;
|
||||
if (system.critical) {
|
||||
output += `\n${system.critical}\n`;
|
||||
}
|
||||
output += '\n';
|
||||
});
|
||||
|
||||
intent.patterns.forEach(pattern => {
|
||||
output += `• ${pattern.description}`;
|
||||
if (pattern.count) output += ` (${pattern.count} changes)`;
|
||||
if (pattern.avgIncrease) output += ` (+${pattern.avgIncrease}px avg)`;
|
||||
output += '\n';
|
||||
});
|
||||
output += '\n';
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
output += '📋 Implementation tasks:\n\n';
|
||||
tasks.forEach((task, i) => {
|
||||
output += `${i + 1}. [${task.priority.toUpperCase()}] ${task.description}\n`;
|
||||
output += `${task.details}\n\n`;
|
||||
});
|
||||
}
|
||||
|
||||
if (changes.content.length > 0) {
|
||||
output += '📝 Content changes (mockup text only, no code changes needed):\n';
|
||||
changes.content.forEach(c => {
|
||||
output += ` - "${c.from}" → "${c.to}"\n`;
|
||||
});
|
||||
output += '\n';
|
||||
}
|
||||
|
||||
return { output, tasks, intent };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const diff = getDiff();
|
||||
|
||||
if (!diff) {
|
||||
console.log('No design changes detected');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const changes = analyzeDiff(diff);
|
||||
const intent = analyzeIntent(changes);
|
||||
const tasks = generateTasks(changes, intent);
|
||||
const result = formatOutput(tasks, changes, intent);
|
||||
|
||||
if (!result) {
|
||||
console.log('No significant design changes detected');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(result.output);
|
||||
|
||||
// Output JSON for automation
|
||||
if (process.argv.includes('--json')) {
|
||||
console.log('\n---JSON---');
|
||||
console.log(JSON.stringify({ tasks, changes, intent }, null, 2));
|
||||
}
|
||||
|
||||
process.exit(tasks.length > 0 ? 1 : 0); // Exit 1 if there are tasks to implement
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,928 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Restructure ui-design.pen into a proper design system.
|
||||
|
||||
Sections (top → bottom):
|
||||
0. Cover — title, TOC
|
||||
1. Foundations — colors, typography, spacing, shadows, heights
|
||||
2. Components — atoms → molecules → organisms (reusable)
|
||||
3. Full Layouts — 6 app variants at 1440×900
|
||||
4. Feature Specs — existing frames reorganized by functional area
|
||||
"""
|
||||
import json
|
||||
import copy
|
||||
import sys
|
||||
import os
|
||||
|
||||
PEN_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ui-design.pen")
|
||||
|
||||
def load():
|
||||
with open(PEN_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save(data):
|
||||
with open(PEN_FILE, "w") as f:
|
||||
json.dump(data, f, separators=(",", ":"))
|
||||
|
||||
# ─── Variable definitions ─────────────────────────────────────────
|
||||
NEW_VARIABLES = {
|
||||
"--spacing-xs": {"type": "number", "value": 4},
|
||||
"--spacing-sm": {"type": "number", "value": 8},
|
||||
"--spacing-md": {"type": "number", "value": 12},
|
||||
"--spacing-lg": {"type": "number", "value": 16},
|
||||
"--spacing-xl": {"type": "number", "value": 24},
|
||||
"--spacing-2xl": {"type": "number", "value": 32},
|
||||
"--spacing-3xl": {"type": "number", "value": 40},
|
||||
"--height-titlebar": {"type": "number", "value": 38},
|
||||
"--height-tabbar": {"type": "number", "value": 45},
|
||||
"--height-breadcrumb": {"type": "number", "value": 45},
|
||||
"--height-statusbar": {"type": "number", "value": 30},
|
||||
"--height-search-bar": {"type": "number", "value": 40},
|
||||
"--height-note-item": {"type": "number", "value": 64},
|
||||
"--height-inspector-header": {"type": "number", "value": 36},
|
||||
"--height-modal-header": {"type": "number", "value": 56},
|
||||
"--height-modal-footer": {"type": "number", "value": 56},
|
||||
"--shadow-sm": {"type": "string", "value": "0 1px 2px rgba(0,0,0,0.05)"},
|
||||
"--shadow-md": {"type": "string", "value": "0 4px 6px rgba(0,0,0,0.07)"},
|
||||
"--shadow-lg": {"type": "string", "value": "0 10px 15px rgba(0,0,0,0.1)"},
|
||||
"--font-mono": {"type": "string", "value": "IBM Plex Mono, monospace"},
|
||||
# Search panel colors (currently hardcoded)
|
||||
"--search-bg": {"type": "color", "value": [
|
||||
{"value": "#FFFFFF"}, {"theme": {"Mode": "Dark"}, "value": "#1E1E2E"}
|
||||
]},
|
||||
"--search-input-bg": {"type": "color", "value": [
|
||||
{"value": "#F0F0EF"}, {"theme": {"Mode": "Dark"}, "value": "#2A2A3C"}
|
||||
]},
|
||||
}
|
||||
|
||||
# ─── Hardcoded color mappings ─────────────────────────────────────
|
||||
# Maps hardcoded hex → variable name for replacement
|
||||
HARDCODED_COLORS = {
|
||||
"#1E1E2E": "$--search-bg",
|
||||
"#1e1e2e": "$--search-bg",
|
||||
"#2A2A3C": "$--search-input-bg",
|
||||
"#2a2a3c": "$--search-input-bg",
|
||||
"#6b7280": "$--muted-foreground",
|
||||
"#f9fafb": "$--muted",
|
||||
"#f3f4f6": "$--muted",
|
||||
"#ffffff": "$--background",
|
||||
"#000000": "$--black",
|
||||
"#6366f1": "$--primary",
|
||||
}
|
||||
|
||||
def replace_hardcoded_colors(node):
|
||||
"""Recursively replace hardcoded hex colors with variable references."""
|
||||
if isinstance(node, dict):
|
||||
for key, val in list(node.items()):
|
||||
if key == "fill" and isinstance(val, str) and val.startswith("#"):
|
||||
lower = val.lower()
|
||||
if lower in HARDCODED_COLORS:
|
||||
node[key] = HARDCODED_COLORS[lower]
|
||||
elif key == "fill" and isinstance(val, dict) and val.get("type") == "color":
|
||||
color = val.get("color", "")
|
||||
if isinstance(color, str) and color.lower() in HARDCODED_COLORS:
|
||||
val["color"] = HARDCODED_COLORS[color.lower()]
|
||||
elif isinstance(val, (dict, list)):
|
||||
replace_hardcoded_colors(val)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
replace_hardcoded_colors(item)
|
||||
|
||||
# ─── Section header helper ────────────────────────────────────────
|
||||
def make_section_header(sec_id, label, x, y):
|
||||
return {
|
||||
"type": "frame", "id": sec_id, "name": label,
|
||||
"x": x, "y": y, "width": 1440, "height": 60,
|
||||
"fill": "$--foreground", "padding": [0, 60],
|
||||
"alignItems": "center", "theme": {"Mode": "Light"},
|
||||
"children": [{
|
||||
"type": "text", "id": f"{sec_id}_lbl",
|
||||
"content": label.upper(),
|
||||
"fill": "$--background", "fontFamily": "Inter",
|
||||
"fontSize": 24, "fontWeight": "700", "letterSpacing": 2
|
||||
}]
|
||||
}
|
||||
|
||||
def make_subsection_label(sub_id, label, x, y):
|
||||
return {
|
||||
"type": "text", "id": sub_id,
|
||||
"content": label,
|
||||
"x": x, "y": y,
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter",
|
||||
"fontSize": 16, "fontWeight": "600", "letterSpacing": 1,
|
||||
"theme": {"Mode": "Light"}
|
||||
}
|
||||
|
||||
# ─── Cover ────────────────────────────────────────────────────────
|
||||
def make_cover(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "ds_cover", "name": "0 — Cover",
|
||||
"x": x, "y": y, "width": 1440, "height": "fit_content(400)",
|
||||
"fill": "$--background", "layout": "vertical",
|
||||
"gap": 32, "padding": [80, 60],
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "ds_title", "content": "Laputa Design System",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 48,
|
||||
"fontWeight": "700", "letterSpacing": -1.5},
|
||||
{"type": "text", "id": "ds_subtitle",
|
||||
"content": "Wiki-linked knowledge management for deep thinkers",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 18, "lineHeight": 1.5},
|
||||
{"type": "rectangle", "id": "ds_div", "width": "fill_container", "height": 1, "fill": "$--border"},
|
||||
{"type": "frame", "id": "ds_toc", "name": "TOC", "layout": "vertical",
|
||||
"width": "fill_container", "gap": 16, "children": [
|
||||
{"type": "text", "id": "ds_toc_t", "content": "Table of Contents",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 20, "fontWeight": "600"},
|
||||
{"type": "frame", "id": "ds_toc_items", "layout": "vertical",
|
||||
"width": "fill_container", "gap": 8, "children": [
|
||||
{"type": "text", "id": "ds_t1", "fill": "$--primary", "fontFamily": "Inter",
|
||||
"fontSize": 14, "fontWeight": "500",
|
||||
"content": "1. Foundations — Colors, Typography, Spacing, Shadows, Heights"},
|
||||
{"type": "text", "id": "ds_t2", "fill": "$--primary", "fontFamily": "Inter",
|
||||
"fontSize": 14, "fontWeight": "500",
|
||||
"content": "2. Components — Atoms, Molecules, Organisms"},
|
||||
{"type": "text", "id": "ds_t3", "fill": "$--primary", "fontFamily": "Inter",
|
||||
"fontSize": 14, "fontWeight": "500",
|
||||
"content": "3. Full Layouts — 6 app variants at 1440×900"},
|
||||
{"type": "text", "id": "ds_t4", "fill": "$--primary", "fontFamily": "Inter",
|
||||
"fontSize": 14, "fontWeight": "500",
|
||||
"content": "4. Feature Specs — All screens grouped by functional area"},
|
||||
]}
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
# ─── Foundation frames ────────────────────────────────────────────
|
||||
def make_spacing_scale(x, y):
|
||||
rows = []
|
||||
for var, px in [("xs", 4), ("sm", 8), ("md", 12), ("lg", 16), ("xl", 24), ("2xl", 32), ("3xl", 40)]:
|
||||
rid = f"sp_{var}"
|
||||
rows.append({
|
||||
"type": "frame", "id": rid, "layout": "horizontal",
|
||||
"gap": 16, "alignItems": "center", "width": "fill_container",
|
||||
"children": [
|
||||
{"type": "rectangle", "id": f"{rid}_box", "width": px, "height": 24,
|
||||
"fill": "$--primary", "cornerRadius": 2},
|
||||
{"type": "text", "id": f"{rid}_lbl",
|
||||
"content": f"--spacing-{var}: {px}px",
|
||||
"fill": "$--foreground", "fontFamily": "IBM Plex Mono", "fontSize": 13}
|
||||
]
|
||||
})
|
||||
return {
|
||||
"type": "frame", "id": "ds_spacing", "name": "Spacing Scale",
|
||||
"x": x, "y": y, "width": 700,
|
||||
"fill": "$--background", "layout": "vertical",
|
||||
"gap": 24, "padding": 40, "theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "ds_sp_title", "content": "Spacing Scale",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 20, "fontWeight": "600"},
|
||||
{"type": "text", "id": "ds_sp_sub",
|
||||
"content": "Consistent spacing tokens used throughout the app.",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 13, "lineHeight": 1.5},
|
||||
] + rows
|
||||
}
|
||||
|
||||
def make_heights_ref(x, y):
|
||||
items = [
|
||||
("titlebar", 38, "$--sidebar"), ("tabbar", 45, "$--sidebar"),
|
||||
("breadcrumb", 45, "$--background"), ("statusbar", 30, "$--sidebar"),
|
||||
("modal-header", 56, "$--background"), ("inspector-header", 36, "$--background"),
|
||||
]
|
||||
rows = []
|
||||
for name, px, fill in items:
|
||||
rid = f"ht_{name.replace('-','_')}"
|
||||
rows.append({
|
||||
"type": "frame", "id": rid, "layout": "horizontal",
|
||||
"gap": 16, "alignItems": "center", "width": "fill_container",
|
||||
"children": [
|
||||
{"type": "rectangle", "id": f"{rid}_bar", "width": 120, "height": px,
|
||||
"fill": fill, "cornerRadius": "$--radius-sm",
|
||||
"stroke": {"fill": "$--border", "thickness": 1}},
|
||||
{"type": "text", "id": f"{rid}_lbl",
|
||||
"content": f"--height-{name}: {px}px",
|
||||
"fill": "$--foreground", "fontFamily": "IBM Plex Mono", "fontSize": 13}
|
||||
]
|
||||
})
|
||||
return {
|
||||
"type": "frame", "id": "ds_heights", "name": "Height Variables",
|
||||
"x": x, "y": y, "width": 700,
|
||||
"fill": "$--background", "layout": "vertical",
|
||||
"gap": 24, "padding": 40, "theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "ds_ht_title", "content": "Height Variables",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 20, "fontWeight": "600"},
|
||||
{"type": "text", "id": "ds_ht_sub",
|
||||
"content": "Fixed heights for key layout regions.",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 13, "lineHeight": 1.5},
|
||||
] + rows
|
||||
}
|
||||
|
||||
def make_shadows_ref(x, y):
|
||||
shadows = [
|
||||
("SM", "0 1px 2px rgba(0,0,0,0.05)", {"type": "shadow", "offset": {"x": 0, "y": 1}, "blur": 2, "color": "#0000000D"}),
|
||||
("MD", "0 4px 6px rgba(0,0,0,0.07)", {"type": "shadow", "offset": {"x": 0, "y": 4}, "blur": 6, "color": "#00000012"}),
|
||||
("LG", "0 10px 15px rgba(0,0,0,0.1)", {"type": "shadow", "offset": {"x": 0, "y": 10}, "blur": 15, "color": "#0000001A"}),
|
||||
]
|
||||
rows = []
|
||||
for name, desc, effect in shadows:
|
||||
rid = f"sh_{name.lower()}"
|
||||
rows.append({
|
||||
"type": "frame", "id": rid, "layout": "horizontal",
|
||||
"gap": 24, "alignItems": "center", "width": "fill_container",
|
||||
"children": [
|
||||
{"type": "frame", "id": f"{rid}_box", "width": 120, "height": 80,
|
||||
"fill": "$--card", "cornerRadius": "$--radius-lg", "effect": effect},
|
||||
{"type": "frame", "id": f"{rid}_info", "layout": "vertical", "gap": 4, "children": [
|
||||
{"type": "text", "id": f"{rid}_name", "content": f"Shadow {name}",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 14, "fontWeight": "600"},
|
||||
{"type": "text", "id": f"{rid}_desc", "content": desc,
|
||||
"fill": "$--muted-foreground", "fontFamily": "IBM Plex Mono", "fontSize": 11}
|
||||
]}
|
||||
]
|
||||
})
|
||||
return {
|
||||
"type": "frame", "id": "ds_shadows", "name": "Shadows",
|
||||
"x": x, "y": y, "width": 700,
|
||||
"fill": "$--background", "layout": "vertical",
|
||||
"gap": 32, "padding": 40, "theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "ds_sh_title", "content": "Shadows",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 20, "fontWeight": "600"},
|
||||
{"type": "text", "id": "ds_sh_sub",
|
||||
"content": "Elevation levels for cards, modals, and popovers.",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 13, "lineHeight": 1.5},
|
||||
] + rows
|
||||
}
|
||||
|
||||
# ─── Reusable Components ─────────────────────────────────────────
|
||||
|
||||
def make_component_status_dot(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_StatusDot", "name": "component/StatusDot",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": "fit_content(6)", "height": "fit_content(6)",
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "ellipse", "id": "StatusDot_dot", "width": 6, "height": 6,
|
||||
"fill": "$--accent-orange"}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_separator(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_Separator", "name": "component/Separator",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": "fill_container(200)", "height": 1,
|
||||
"fill": "$--border",
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": []
|
||||
}
|
||||
|
||||
def make_component_badge(x, y):
|
||||
"""Colored pill badge — used for type badges, status badges, tags."""
|
||||
return {
|
||||
"type": "frame", "id": "comp_Badge", "name": "component/Badge",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"cornerRadius": 12, "fill": "$--accent-blue-light",
|
||||
"padding": [2, 8],
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "Badge_label", "content": "Badge",
|
||||
"fill": "$--accent-blue", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500"}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_button_primary(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_ButtonPrimary", "name": "component/Button/Primary",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"cornerRadius": "$--radius-md", "fill": "$--primary",
|
||||
"padding": [8, 16], "gap": 6, "alignItems": "center",
|
||||
"justifyContent": "center", "height": 36,
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "BtnPri_label", "content": "Button",
|
||||
"fill": "$--primary-foreground", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500"}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_button_secondary(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_ButtonSecondary", "name": "component/Button/Secondary",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"cornerRadius": "$--radius-md", "fill": "$--secondary",
|
||||
"padding": [8, 16], "gap": 6, "alignItems": "center",
|
||||
"justifyContent": "center", "height": 36,
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "BtnSec_label", "content": "Button",
|
||||
"fill": "$--secondary-foreground", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500"}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_button_ghost(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_ButtonGhost", "name": "component/Button/Ghost",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"cornerRadius": "$--radius-md",
|
||||
"padding": [8, 16], "gap": 6, "alignItems": "center",
|
||||
"justifyContent": "center", "height": 36,
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "BtnGho_label", "content": "Button",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500"}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_input(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_Input", "name": "component/Input",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": 240, "height": 36,
|
||||
"cornerRadius": "$--radius-md", "fill": "$--background",
|
||||
"stroke": {"fill": "$--input", "thickness": 1, "align": "inside"},
|
||||
"padding": [0, 12], "alignItems": "center",
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "Input_placeholder", "content": "Placeholder...",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 13}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_icon_button(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_IconButton", "name": "component/IconButton",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": 28, "height": 28,
|
||||
"cornerRadius": "$--radius-sm",
|
||||
"alignItems": "center", "justifyContent": "center",
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "icon_font", "id": "IconBtn_icon", "width": 16, "height": 16,
|
||||
"fill": "$--muted-foreground", "iconFontFamily": "lucide", "iconFontName": "plus"}
|
||||
]
|
||||
}
|
||||
|
||||
# ─── Molecule Components ──────────────────────────────────────────
|
||||
|
||||
def make_component_inspector_header(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_InspectorHeader", "name": "component/InspectorHeader",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": "fill_container(320)", "height": "$--height-inspector-header",
|
||||
"justifyContent": "space_between", "alignItems": "center",
|
||||
"padding": [0, 12],
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "frame", "id": "IH_left", "name": "leftGroup",
|
||||
"gap": 6, "alignItems": "center", "children": [
|
||||
{"type": "icon_font", "id": "IH_icon", "width": 16, "height": 16,
|
||||
"fill": "$--muted-foreground", "iconFontFamily": "phosphor",
|
||||
"iconFontName": "sliders-horizontal"},
|
||||
{"type": "text", "id": "IH_title", "content": "Properties",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter",
|
||||
"fontSize": 13, "fontWeight": "600"}
|
||||
]},
|
||||
{"type": "icon_font", "id": "IH_close", "width": 16, "height": 16,
|
||||
"fill": "$--muted-foreground", "iconFontFamily": "phosphor", "iconFontName": "x"}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_note_list_item(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_NoteListItem", "name": "component/NoteListItem",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": "fill_container(340)", "layout": "vertical",
|
||||
"gap": 4, "padding": [14, 16],
|
||||
"stroke": {"align": "inside", "fill": "$--border", "thickness": {"bottom": 1}},
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "frame", "id": "NLI_titleRow", "name": "titleRow",
|
||||
"width": "fill_container", "justifyContent": "space_between", "alignItems": "center",
|
||||
"children": [
|
||||
{"type": "frame", "id": "NLI_titleGroup", "gap": 6, "alignItems": "center",
|
||||
"children": [
|
||||
{"type": "text", "id": "NLI_title", "content": "Note Title",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500"}
|
||||
]},
|
||||
{"type": "text", "id": "NLI_time", "content": "2m ago",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 11}
|
||||
]},
|
||||
{"type": "text", "id": "NLI_snippet",
|
||||
"content": "Preview text of the note content...",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter",
|
||||
"fontSize": 12, "lineHeight": 1.5,
|
||||
"textGrowth": "fixed-width", "width": "fill_container"},
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_tab_active(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_TabActive", "name": "component/Tab/Active",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"height": "fill_container(45)", "gap": 6,
|
||||
"fill": "$--background", "alignItems": "center",
|
||||
"padding": [0, 14],
|
||||
"stroke": {"align": "inside", "fill": "$--border", "thickness": {"right": 1}},
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "TabA_label", "content": "Tab Title",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500"},
|
||||
{"type": "icon_font", "id": "TabA_close", "width": 14, "height": 14,
|
||||
"fill": "$--muted-foreground", "iconFontFamily": "lucide", "iconFontName": "x"}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_tab_inactive(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_TabInactive", "name": "component/Tab/Inactive",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"height": "fill_container(45)", "gap": 6,
|
||||
"alignItems": "center", "padding": [0, 14],
|
||||
"stroke": {"align": "inside", "fill": "$--sidebar-border",
|
||||
"thickness": {"bottom": 1, "right": 1}},
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "TabI_label", "content": "Tab Title",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 12},
|
||||
{"type": "icon_font", "id": "TabI_close", "width": 14, "height": 14,
|
||||
"fill": "$--muted-foreground", "iconFontFamily": "lucide", "iconFontName": "x",
|
||||
"opacity": 0}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_property_row(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_PropertyRow", "name": "component/PropertyRow",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": "fill_container(300)", "alignItems": "center",
|
||||
"cornerRadius": 4, "padding": [4, 6],
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "PR_label", "content": "LABEL",
|
||||
"fill": "$--muted-foreground", "fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 10, "fontWeight": "500", "letterSpacing": 1.2},
|
||||
{"type": "text", "id": "PR_value", "content": "Value",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_relationship_pill(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_RelationshipPill", "name": "component/RelationshipPill",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": "fill_container(280)", "cornerRadius": 6,
|
||||
"fill": "$--accent-blue-light", "gap": 6,
|
||||
"justifyContent": "space_between", "alignItems": "center",
|
||||
"padding": [6, 10],
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "RP_title", "content": "Related Note",
|
||||
"fill": "$--accent-blue", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500"},
|
||||
{"type": "icon_font", "id": "RP_icon", "width": 14, "height": 14,
|
||||
"fill": "$--accent-blue", "iconFontFamily": "phosphor", "iconFontName": "wrench",
|
||||
"weight": 700}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_sidebar_filter_item(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_SidebarFilterItem", "name": "component/SidebarFilterItem",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": "fill_container(250)", "cornerRadius": 6,
|
||||
"gap": 8, "alignItems": "center", "padding": [6, 16],
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "icon_font", "id": "SFI_icon", "width": 16, "height": 16,
|
||||
"fill": "$--muted-foreground", "iconFontFamily": "lucide", "iconFontName": "folder"},
|
||||
{"type": "text", "id": "SFI_label", "content": "Filter Item",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13},
|
||||
{"type": "text", "id": "SFI_count", "content": "42",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 11}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_section_label(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_SectionLabel", "name": "component/SectionLabel",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": "fill_container(280)", "padding": [0, 12],
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "SL_label", "content": "SECTION",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter",
|
||||
"fontSize": 10, "fontWeight": "600", "letterSpacing": 1.2}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_section_count_header(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_SectionCountHeader", "name": "component/SectionCountHeader",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": "fill_container(280)", "justifyContent": "space_between",
|
||||
"alignItems": "center", "padding": [4, 0],
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "SCH_label", "content": "SECTION",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter",
|
||||
"fontSize": 10, "fontWeight": "600", "letterSpacing": 1.2},
|
||||
{"type": "text", "id": "SCH_count", "content": "3",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 11}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_add_button(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_AddButton", "name": "component/AddButton",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": "fill_container(280)", "height": 32,
|
||||
"cornerRadius": 6, "justifyContent": "center", "alignItems": "center",
|
||||
"stroke": {"align": "inside", "fill": "$--border", "thickness": 1},
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "AB_label", "content": "+ Add property",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 12}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_rel_group_label(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_RelGroupLabel", "name": "component/RelGroupLabel",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": "fill_container(280)", "justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "RGL_label", "content": "BELONGS TO",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter",
|
||||
"fontSize": 10, "fontWeight": "600", "letterSpacing": 0.5},
|
||||
{"type": "text", "id": "RGL_count", "content": "3",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 10}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_command_palette_item(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_CommandPaletteItem", "name": "component/CommandPaletteItem",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": "fill_container(460)", "height": 40,
|
||||
"alignItems": "center", "gap": 12, "padding": [0, 16],
|
||||
"cornerRadius": "$--radius-md",
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "icon_font", "id": "CPI_icon", "width": 16, "height": 16,
|
||||
"fill": "$--muted-foreground", "iconFontFamily": "lucide", "iconFontName": "file-text"},
|
||||
{"type": "frame", "id": "CPI_content", "layout": "vertical",
|
||||
"width": "fill_container", "gap": 2, "children": [
|
||||
{"type": "text", "id": "CPI_title", "content": "Command Name",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500"},
|
||||
]},
|
||||
{"type": "text", "id": "CPI_shortcut", "content": "⌘K",
|
||||
"fill": "$--muted-foreground", "fontFamily": "IBM Plex Mono", "fontSize": 11}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_editable_value(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_EditableValue", "name": "component/EditableValue",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": "fill_container(200)",
|
||||
"alignItems": "center", "gap": 4,
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "text", "id": "EV_value", "content": "Editable Value",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13},
|
||||
{"type": "icon_font", "id": "EV_edit", "width": 12, "height": 12,
|
||||
"fill": "$--muted-foreground", "iconFontFamily": "lucide", "iconFontName": "pencil",
|
||||
"opacity": 0}
|
||||
]
|
||||
}
|
||||
|
||||
# ─── Organism Components ──────────────────────────────────────────
|
||||
|
||||
def make_component_toast(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_Toast", "name": "component/Toast",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": 360, "cornerRadius": "$--radius-lg",
|
||||
"fill": "$--card", "padding": [12, 16], "gap": 12,
|
||||
"alignItems": "center",
|
||||
"stroke": {"fill": "$--border", "thickness": 1},
|
||||
"effect": {"type": "shadow", "offset": {"x": 0, "y": 4}, "blur": 12, "color": "#0000001A"},
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "icon_font", "id": "Toast_icon", "width": 20, "height": 20,
|
||||
"fill": "$--accent-green", "iconFontFamily": "lucide", "iconFontName": "check-circle"},
|
||||
{"type": "frame", "id": "Toast_content", "layout": "vertical",
|
||||
"width": "fill_container", "gap": 2, "children": [
|
||||
{"type": "text", "id": "Toast_title", "content": "Success",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600"},
|
||||
{"type": "text", "id": "Toast_msg", "content": "Your changes have been saved.",
|
||||
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 12, "lineHeight": 1.4}
|
||||
]},
|
||||
{"type": "icon_font", "id": "Toast_close", "width": 14, "height": 14,
|
||||
"fill": "$--muted-foreground", "iconFontFamily": "lucide", "iconFontName": "x"}
|
||||
]
|
||||
}
|
||||
|
||||
def make_component_ai_chat_message(x, y):
|
||||
return {
|
||||
"type": "frame", "id": "comp_AIChatMessage", "name": "component/AIChatMessage",
|
||||
"reusable": True, "x": x, "y": y,
|
||||
"width": "fill_container(400)", "layout": "horizontal",
|
||||
"gap": 12, "padding": [12, 16],
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": [
|
||||
{"type": "frame", "id": "AIM_avatar", "width": 28, "height": 28,
|
||||
"cornerRadius": 14, "fill": "$--accent-purple-light",
|
||||
"alignItems": "center", "justifyContent": "center",
|
||||
"children": [
|
||||
{"type": "icon_font", "id": "AIM_avatarIcon", "width": 16, "height": 16,
|
||||
"fill": "$--accent-purple", "iconFontFamily": "lucide", "iconFontName": "sparkles"}
|
||||
]},
|
||||
{"type": "frame", "id": "AIM_body", "layout": "vertical",
|
||||
"width": "fill_container", "gap": 4, "children": [
|
||||
{"type": "text", "id": "AIM_sender", "content": "AI Assistant",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "600"},
|
||||
{"type": "text", "id": "AIM_text",
|
||||
"content": "Here's a summary of your recent notes...",
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13,
|
||||
"lineHeight": 1.5, "textGrowth": "fixed-width", "width": "fill_container"}
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
# ─── Component showcase frame ─────────────────────────────────────
|
||||
def make_component_showcase(x, y, title, components):
|
||||
"""Creates a frame showing all component instances in a row for visual reference."""
|
||||
children = [
|
||||
{"type": "text", "id": f"showcase_{title.replace(' ','_')}_title",
|
||||
"content": title,
|
||||
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 18, "fontWeight": "600"},
|
||||
]
|
||||
for comp_id, label in components:
|
||||
children.append({
|
||||
"type": "frame", "id": f"show_{comp_id}", "layout": "vertical", "gap": 8,
|
||||
"padding": [12, 0], "children": [
|
||||
{"type": "text", "id": f"show_{comp_id}_lbl", "content": label,
|
||||
"fill": "$--muted-foreground", "fontFamily": "IBM Plex Mono", "fontSize": 10,
|
||||
"letterSpacing": 0.5},
|
||||
{"type": "ref", "id": f"show_{comp_id}_ref", "ref": comp_id}
|
||||
]
|
||||
})
|
||||
sid = f"showcase_{title.replace(' ', '_')}"
|
||||
return {
|
||||
"type": "frame", "id": sid, "name": f"Showcase — {title}",
|
||||
"x": x, "y": y, "width": 1440,
|
||||
"fill": "$--background", "layout": "vertical",
|
||||
"gap": 24, "padding": 40,
|
||||
"theme": {"Mode": "Light"},
|
||||
"children": children
|
||||
}
|
||||
|
||||
# ─── Feature spec grouping ────────────────────────────────────────
|
||||
FEATURE_GROUPS = {
|
||||
"Tabs & Navigation": [
|
||||
"dt0", "dtj", "dt11", "rnt0", "rne0", "rns0",
|
||||
"archBtnNorm", "archBtnArc", "srtDD", "srtBN"
|
||||
],
|
||||
"Inspector & Properties": [
|
||||
"pi01", "pi50", "rbF01", "rbF02", "rbF03",
|
||||
"reF01", "reF02", "reF03",
|
||||
"urlDefault", "urlHover", "urlEdit"
|
||||
],
|
||||
"Note List & Virtual List": [
|
||||
"vl001", "vl100", "vl200", "vl300",
|
||||
"mni01", "dp01"
|
||||
],
|
||||
"Sidebar & Sections": [
|
||||
"dsg01a", "dsg02w", "dsg04j", "csB01", "csA01", "trSB1"
|
||||
],
|
||||
"Modals & Settings": [
|
||||
"iogBH", "ghv01", "ghv02", "ghv03", "ghv04"
|
||||
],
|
||||
"Editor & Wikilinks": [
|
||||
"wlc01", "wlc20", "mb001", "mb011"
|
||||
],
|
||||
"Status & Indicators": [
|
||||
"mni10", "mni20", "dp10", "dp20", "dp30"
|
||||
],
|
||||
"Git & Changes": [
|
||||
"vc001", "vc100", "vc200", "ghCL1", "ghDO1"
|
||||
],
|
||||
"Search": [
|
||||
"3aG9b", "K1O2x", "nrIcZ"
|
||||
],
|
||||
"Trash": [
|
||||
"trNL1", "trRI1", "trW30"
|
||||
],
|
||||
}
|
||||
|
||||
# ─── Main transform ──────────────────────────────────────────────
|
||||
|
||||
def transform():
|
||||
data = load()
|
||||
|
||||
# 1. Add new variables (merge, don't replace)
|
||||
for k, v in NEW_VARIABLES.items():
|
||||
data["variables"][k] = v
|
||||
|
||||
# 2. Build an index of existing children by ID
|
||||
existing = {c["id"]: c for c in data["children"]}
|
||||
|
||||
# 3. Fix hardcoded colors in ALL existing frames
|
||||
for child in data["children"]:
|
||||
replace_hardcoded_colors(child)
|
||||
|
||||
# 4. Build new ordered children list
|
||||
new_children = []
|
||||
|
||||
# ─── Section 0: Cover (y=0) ───
|
||||
new_children.append(make_cover(0, 0))
|
||||
|
||||
# ─── Section 1: Foundations (y=500) ───
|
||||
new_children.append(make_section_header("sec1_hdr", "1 — Foundations", 0, 500))
|
||||
|
||||
# Move existing Color Palette
|
||||
if "mOf4J" in existing:
|
||||
cp = existing.pop("mOf4J")
|
||||
cp["x"] = 0; cp["y"] = 600
|
||||
new_children.append(cp)
|
||||
|
||||
# Move existing Typography & Spacing
|
||||
if "HZonq" in existing:
|
||||
ts = existing.pop("HZonq")
|
||||
ts["x"] = 0; ts["y"] = 1600
|
||||
new_children.append(ts)
|
||||
|
||||
# New foundation frames
|
||||
new_children.append(make_spacing_scale(0, 3000))
|
||||
new_children.append(make_heights_ref(800, 3000))
|
||||
new_children.append(make_shadows_ref(0, 3600))
|
||||
|
||||
# ─── Section 2: Components (y=4200) ───
|
||||
new_children.append(make_section_header("sec2_hdr", "2 — Components", 0, 4200))
|
||||
|
||||
# Subsection labels
|
||||
new_children.append(make_subsection_label("sub_atoms", "ATOMS", 0, 4290))
|
||||
|
||||
# Atom components (placed as reusable definitions)
|
||||
comp_y_atoms = 4320
|
||||
atom_components = [
|
||||
make_component_status_dot(0, comp_y_atoms),
|
||||
make_component_separator(100, comp_y_atoms),
|
||||
make_component_badge(400, comp_y_atoms),
|
||||
make_component_button_primary(0, comp_y_atoms + 60),
|
||||
make_component_button_secondary(200, comp_y_atoms + 60),
|
||||
make_component_button_ghost(400, comp_y_atoms + 60),
|
||||
make_component_input(0, comp_y_atoms + 120),
|
||||
make_component_icon_button(300, comp_y_atoms + 120),
|
||||
]
|
||||
new_children.extend(atom_components)
|
||||
|
||||
# Atom showcase
|
||||
new_children.append(make_component_showcase(0, comp_y_atoms + 200, "Atoms", [
|
||||
("comp_StatusDot", "StatusDot"),
|
||||
("comp_Separator", "Separator"),
|
||||
("comp_Badge", "Badge"),
|
||||
("comp_ButtonPrimary", "Button/Primary"),
|
||||
("comp_ButtonSecondary", "Button/Secondary"),
|
||||
("comp_ButtonGhost", "Button/Ghost"),
|
||||
("comp_Input", "Input"),
|
||||
("comp_IconButton", "IconButton"),
|
||||
]))
|
||||
|
||||
# Subsection: Molecules
|
||||
mol_y = 4900
|
||||
new_children.append(make_subsection_label("sub_molecules", "MOLECULES", 0, mol_y - 30))
|
||||
|
||||
molecule_components = [
|
||||
make_component_inspector_header(0, mol_y),
|
||||
make_component_note_list_item(0, mol_y + 60),
|
||||
make_component_tab_active(400, mol_y),
|
||||
make_component_tab_inactive(600, mol_y),
|
||||
make_component_property_row(0, mol_y + 160),
|
||||
make_component_relationship_pill(0, mol_y + 220),
|
||||
make_component_sidebar_filter_item(400, mol_y + 160),
|
||||
make_component_section_label(0, mol_y + 280),
|
||||
make_component_section_count_header(400, mol_y + 280),
|
||||
make_component_add_button(0, mol_y + 340),
|
||||
make_component_rel_group_label(400, mol_y + 340),
|
||||
make_component_command_palette_item(0, mol_y + 400),
|
||||
make_component_editable_value(0, mol_y + 460),
|
||||
]
|
||||
new_children.extend(molecule_components)
|
||||
|
||||
# Molecule showcase
|
||||
new_children.append(make_component_showcase(0, mol_y + 520, "Molecules", [
|
||||
("comp_InspectorHeader", "InspectorHeader"),
|
||||
("comp_NoteListItem", "NoteListItem"),
|
||||
("comp_TabActive", "Tab/Active"),
|
||||
("comp_TabInactive", "Tab/Inactive"),
|
||||
("comp_PropertyRow", "PropertyRow"),
|
||||
("comp_RelationshipPill", "RelationshipPill"),
|
||||
("comp_SidebarFilterItem", "SidebarFilterItem"),
|
||||
("comp_SectionLabel", "SectionLabel"),
|
||||
("comp_SectionCountHeader", "SectionCountHeader"),
|
||||
("comp_AddButton", "AddButton"),
|
||||
("comp_RelGroupLabel", "RelGroupLabel"),
|
||||
("comp_CommandPaletteItem", "CommandPaletteItem"),
|
||||
("comp_EditableValue", "EditableValue"),
|
||||
]))
|
||||
|
||||
# Subsection: Organisms
|
||||
org_y = 5900
|
||||
new_children.append(make_subsection_label("sub_organisms", "ORGANISMS", 0, org_y - 30))
|
||||
|
||||
new_children.append(make_component_toast(0, org_y))
|
||||
new_children.append(make_component_ai_chat_message(0, org_y + 80))
|
||||
|
||||
new_children.append(make_component_showcase(0, org_y + 200, "Organisms", [
|
||||
("comp_Toast", "Toast"),
|
||||
("comp_AIChatMessage", "AIChatMessage"),
|
||||
]))
|
||||
|
||||
# ─── Section 3: Full Layouts (y=6600) ───
|
||||
new_children.append(make_section_header("sec3_hdr", "3 — Full Layouts", 0, 6600))
|
||||
|
||||
# Layout frames — move existing ones
|
||||
layout_ids = ["qHhaj", "SC_all", "SC_nosb", "SC_edonly", "SC_ednl"]
|
||||
layout_y = 6700
|
||||
for lid in layout_ids:
|
||||
if lid in existing:
|
||||
frame = existing.pop(lid)
|
||||
frame["x"] = 0
|
||||
frame["y"] = layout_y
|
||||
new_children.append(frame)
|
||||
layout_y += 1000 # 900px height + 100px gap
|
||||
|
||||
# ─── Section 4: Feature Specs (y=12200) ───
|
||||
spec_y = 12200
|
||||
new_children.append(make_section_header("sec4_hdr", "4 — Feature Specs", 0, spec_y))
|
||||
spec_y += 100
|
||||
|
||||
placed_ids = set()
|
||||
for group_name, group_ids in FEATURE_GROUPS.items():
|
||||
# Group label
|
||||
new_children.append(make_subsection_label(
|
||||
f"fg_{group_name.replace(' ', '_').replace('&','and').lower()[:20]}",
|
||||
group_name.upper(), 0, spec_y
|
||||
))
|
||||
spec_y += 30
|
||||
|
||||
group_x = 0
|
||||
max_h = 0
|
||||
for fid in group_ids:
|
||||
if fid in existing:
|
||||
frame = existing.pop(fid)
|
||||
fw = frame.get("width", 300)
|
||||
if isinstance(fw, str):
|
||||
fw = 340 # fallback for dynamic widths
|
||||
|
||||
# Wrap to next row if too wide
|
||||
if group_x + fw > 3000:
|
||||
spec_y += max_h + 40
|
||||
group_x = 0
|
||||
max_h = 0
|
||||
|
||||
frame["x"] = group_x
|
||||
frame["y"] = spec_y
|
||||
new_children.append(frame)
|
||||
placed_ids.add(fid)
|
||||
|
||||
fh = frame.get("height", 200)
|
||||
if isinstance(fh, str):
|
||||
fh = 400 # fallback
|
||||
max_h = max(max_h, fh)
|
||||
group_x += fw + 100
|
||||
|
||||
spec_y += max_h + 80
|
||||
|
||||
# Any remaining unplaced frames go at the end
|
||||
for fid, frame in existing.items():
|
||||
if fid not in placed_ids:
|
||||
frame["x"] = 0
|
||||
frame["y"] = spec_y
|
||||
new_children.append(frame)
|
||||
fh = frame.get("height", 200)
|
||||
if isinstance(fh, str):
|
||||
fh = 400
|
||||
spec_y += fh + 100
|
||||
|
||||
# 5. Replace children
|
||||
data["children"] = new_children
|
||||
|
||||
# 6. Save
|
||||
save(data)
|
||||
|
||||
# Stats
|
||||
reusable_count = sum(1 for c in new_children if c.get("reusable"))
|
||||
print(f"✅ Restructured ui-design.pen")
|
||||
print(f" Total nodes: {len(new_children)}")
|
||||
print(f" Reusable components: {reusable_count}")
|
||||
print(f" Variables: {len(data['variables'])}")
|
||||
print(f" Hardcoded colors replaced in existing frames")
|
||||
print(f" Canvas organized into 5 sections")
|
||||
|
||||
if __name__ == "__main__":
|
||||
transform()
|
||||
178
src-tauri/Cargo.lock
generated
178
src-tauri/Cargo.lock
generated
@@ -123,16 +123,6 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "assert-json-diff"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -619,15 +609,6 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -663,16 +644,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -696,7 +667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
@@ -709,7 +680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1701,25 +1672,6 @@ dependencies = [
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1871,11 +1823,9 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
@@ -1934,11 +1884,9 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2449,31 +2397,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mockito"
|
||||
version = "1.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
|
||||
dependencies = [
|
||||
"assert-json-diff",
|
||||
"bytes",
|
||||
"colored",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"similar",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.17.1"
|
||||
@@ -3434,16 +3357,6 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -3464,16 +3377,6 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -3492,15 +3395,6 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -3631,21 +3525,17 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -3656,14 +3546,12 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -3702,7 +3590,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.5.0",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -3858,7 +3746,7 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
@@ -3990,7 +3878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -4364,12 +4252,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
@@ -4569,27 +4451,6 @@ dependencies = [
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -4611,7 +4472,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dispatch",
|
||||
@@ -5182,7 +5043,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
@@ -5243,9 +5103,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"gray_matter",
|
||||
"log",
|
||||
"mockito",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5834,19 +5692,6 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
@@ -6120,17 +5965,6 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
|
||||
@@ -25,7 +25,6 @@ tauri-plugin-log = "2"
|
||||
gray_matter = "0.2"
|
||||
walkdir = "2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
futures-util = "0.3"
|
||||
base64 = "0.22"
|
||||
@@ -41,4 +40,3 @@ uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
mockito = "1"
|
||||
|
||||
463
src-tauri/src/ai_agents.rs
Normal file
463
src-tauri/src/ai_agents.rs
Normal file
@@ -0,0 +1,463 @@
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AiAgentAvailability {
|
||||
pub installed: bool,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AiAgentsStatus {
|
||||
pub claude_code: AiAgentAvailability,
|
||||
pub codex: AiAgentAvailability,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum AiAgentStreamEvent {
|
||||
Init {
|
||||
session_id: String,
|
||||
},
|
||||
TextDelta {
|
||||
text: String,
|
||||
},
|
||||
ThinkingDelta {
|
||||
text: String,
|
||||
},
|
||||
ToolStart {
|
||||
tool_name: String,
|
||||
tool_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
input: Option<String>,
|
||||
},
|
||||
ToolDone {
|
||||
tool_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
output: Option<String>,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
Done,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AiAgentStreamRequest {
|
||||
pub agent: AiAgentId,
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
}
|
||||
|
||||
pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
AiAgentsStatus {
|
||||
claude_code: availability_from_claude(),
|
||||
codex: availability_from_codex(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_ai_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
match request.agent {
|
||||
AiAgentId::ClaudeCode => {
|
||||
let mapped = crate::claude_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
};
|
||||
crate::claude_cli::run_agent_stream(mapped, |event| {
|
||||
if let Some(mapped_event) = map_claude_event(event) {
|
||||
emit(mapped_event);
|
||||
}
|
||||
})
|
||||
}
|
||||
AiAgentId::Codex => run_codex_agent_stream(request, emit),
|
||||
}
|
||||
}
|
||||
|
||||
fn availability_from_claude() -> AiAgentAvailability {
|
||||
let status = crate::claude_cli::check_cli();
|
||||
AiAgentAvailability {
|
||||
installed: status.installed,
|
||||
version: status.version,
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
"--dangerously-bypass-approvals-and-sandbox".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 } => {
|
||||
Some(AiAgentStreamEvent::Init { session_id })
|
||||
}
|
||||
crate::claude_cli::ClaudeStreamEvent::TextDelta { text } => {
|
||||
Some(AiAgentStreamEvent::TextDelta { text })
|
||||
}
|
||||
crate::claude_cli::ClaudeStreamEvent::ThinkingDelta { text } => {
|
||||
Some(AiAgentStreamEvent::ThinkingDelta { text })
|
||||
}
|
||||
crate::claude_cli::ClaudeStreamEvent::ToolStart {
|
||||
tool_name,
|
||||
tool_id,
|
||||
input,
|
||||
} => Some(AiAgentStreamEvent::ToolStart {
|
||||
tool_name,
|
||||
tool_id,
|
||||
input,
|
||||
}),
|
||||
crate::claude_cli::ClaudeStreamEvent::ToolDone { tool_id, output } => {
|
||||
Some(AiAgentStreamEvent::ToolDone { tool_id, output })
|
||||
}
|
||||
crate::claude_cli::ClaudeStreamEvent::Error { message } => {
|
||||
Some(AiAgentStreamEvent::Error { message })
|
||||
}
|
||||
crate::claude_cli::ClaudeStreamEvent::Done
|
||||
| crate::claude_cli::ClaudeStreamEvent::Result { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_status_contains_both_agents() {
|
||||
let status = get_ai_agents_status();
|
||||
assert!(matches!(status.claude_code.installed, true | false));
|
||||
assert!(matches!(status.codex.installed, true | false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_codex_prompt(&AiAgentStreamRequest {
|
||||
agent: AiAgentId::Codex,
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: Some("Be concise".into()),
|
||||
vault_path: "/tmp/vault".into(),
|
||||
});
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
179
src-tauri/src/app_updater.rs
Normal file
179
src-tauri/src/app_updater.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
use serde::Serialize;
|
||||
use tauri::{ipc::Channel, AppHandle, Runtime, Url};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
const RELEASES_BASE_URL: &str = "https://refactoringhq.github.io/tolaria";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppUpdateMetadata {
|
||||
pub current_version: String,
|
||||
pub version: String,
|
||||
pub date: Option<String>,
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
pub enum AppUpdateDownloadEvent {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Started {
|
||||
content_length: Option<u64>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Progress {
|
||||
chunk_length: usize,
|
||||
},
|
||||
Finished,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ReleaseChannel {
|
||||
Alpha,
|
||||
Stable,
|
||||
}
|
||||
|
||||
impl ReleaseChannel {
|
||||
fn from_settings_value(value: Option<&str>) -> Self {
|
||||
match crate::settings::effective_release_channel(value) {
|
||||
"alpha" => Self::Alpha,
|
||||
_ => Self::Stable,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Alpha => "alpha",
|
||||
Self::Stable => "stable",
|
||||
}
|
||||
}
|
||||
|
||||
fn updater_endpoint(self) -> Result<Url, String> {
|
||||
let endpoint = format!("{}/{}/latest.json", RELEASES_BASE_URL, self.as_str());
|
||||
Url::parse(&endpoint).map_err(|e| format!("Invalid updater endpoint: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_updater<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
release_channel: ReleaseChannel,
|
||||
) -> Result<tauri_plugin_updater::Updater, String> {
|
||||
app_handle
|
||||
.updater_builder()
|
||||
.endpoints(vec![release_channel.updater_endpoint()?])
|
||||
.map_err(|e| format!("Failed to configure updater endpoint: {e}"))?
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build updater: {e}"))
|
||||
}
|
||||
|
||||
fn to_update_metadata(update: tauri_plugin_updater::Update) -> AppUpdateMetadata {
|
||||
AppUpdateMetadata {
|
||||
current_version: update.current_version,
|
||||
version: update.version,
|
||||
date: update.date.map(|value| value.to_string()),
|
||||
body: update.body,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_for_app_update<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
release_channel: Option<String>,
|
||||
) -> Result<Option<AppUpdateMetadata>, String> {
|
||||
let channel = ReleaseChannel::from_settings_value(release_channel.as_deref());
|
||||
let updater = build_updater(&app_handle, channel)?;
|
||||
let update = updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to check for updates: {e}"))?;
|
||||
|
||||
Ok(update.map(to_update_metadata))
|
||||
}
|
||||
|
||||
pub async fn download_and_install_app_update<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
release_channel: Option<String>,
|
||||
expected_version: String,
|
||||
on_event: Channel<AppUpdateDownloadEvent>,
|
||||
) -> Result<(), String> {
|
||||
let channel = ReleaseChannel::from_settings_value(release_channel.as_deref());
|
||||
let updater = build_updater(&app_handle, channel)?;
|
||||
let update = updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to refresh update metadata: {e}"))?
|
||||
.ok_or_else(|| "No update is currently available".to_string())?;
|
||||
|
||||
if update.version != expected_version {
|
||||
return Err(format!(
|
||||
"Expected update version {}, found {}",
|
||||
expected_version, update.version
|
||||
));
|
||||
}
|
||||
|
||||
let mut started = false;
|
||||
update
|
||||
.download_and_install(
|
||||
|chunk_length, content_length| {
|
||||
if !started {
|
||||
started = true;
|
||||
let _ = on_event.send(AppUpdateDownloadEvent::Started { content_length });
|
||||
}
|
||||
|
||||
let _ = on_event.send(AppUpdateDownloadEvent::Progress { chunk_length });
|
||||
},
|
||||
|| {
|
||||
let _ = on_event.send(AppUpdateDownloadEvent::Finished);
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download and install update: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ReleaseChannel;
|
||||
|
||||
#[test]
|
||||
fn release_channel_defaults_to_stable() {
|
||||
assert_eq!(
|
||||
ReleaseChannel::from_settings_value(None),
|
||||
ReleaseChannel::Stable
|
||||
);
|
||||
assert_eq!(
|
||||
ReleaseChannel::from_settings_value(Some("stable")),
|
||||
ReleaseChannel::Stable
|
||||
);
|
||||
assert_eq!(
|
||||
ReleaseChannel::from_settings_value(Some("beta")),
|
||||
ReleaseChannel::Stable
|
||||
);
|
||||
assert_eq!(
|
||||
ReleaseChannel::from_settings_value(Some("invalid")),
|
||||
ReleaseChannel::Stable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_channel_accepts_alpha() {
|
||||
assert_eq!(
|
||||
ReleaseChannel::from_settings_value(Some("alpha")),
|
||||
ReleaseChannel::Alpha
|
||||
);
|
||||
assert_eq!(
|
||||
ReleaseChannel::from_settings_value(Some(" alpha ")),
|
||||
ReleaseChannel::Alpha
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_channel_endpoints_match_expected_paths() {
|
||||
assert_eq!(
|
||||
ReleaseChannel::Alpha.updater_endpoint().unwrap().as_str(),
|
||||
"https://refactoringhq.github.io/tolaria/alpha/latest.json"
|
||||
);
|
||||
assert_eq!(
|
||||
ReleaseChannel::Stable.updater_endpoint().unwrap().as_str(),
|
||||
"https://refactoringhq.github.io/tolaria/stable/latest.json"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,51 @@
|
||||
#[cfg(desktop)]
|
||||
use crate::claude_cli::ClaudeStreamEvent;
|
||||
use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus};
|
||||
use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus};
|
||||
use crate::vault::VaultAiGuidanceStatus;
|
||||
|
||||
use super::expand_tilde;
|
||||
|
||||
#[cfg(desktop)]
|
||||
type StreamEmitter<Event> = Box<dyn Fn(Event) + Send>;
|
||||
|
||||
#[cfg(desktop)]
|
||||
async fn run_desktop_stream<Event, Request, Runner>(
|
||||
app_handle: tauri::AppHandle,
|
||||
event_name: &'static str,
|
||||
request: Request,
|
||||
runner: Runner,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
Event: serde::Serialize + Send + 'static,
|
||||
Request: Send + 'static,
|
||||
Runner: FnOnce(Request, StreamEmitter<Event>) -> Result<String, String> + Send + 'static,
|
||||
{
|
||||
use tauri::Emitter;
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
runner(
|
||||
request,
|
||||
Box::new(move |event| {
|
||||
let _ = app_handle.emit(event_name, &event);
|
||||
}),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
macro_rules! define_desktop_stream_command {
|
||||
($name:ident, $request:ty, $event_name:literal, $runner:path) => {
|
||||
#[tauri::command]
|
||||
pub async fn $name(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: $request,
|
||||
) -> Result<String, String> {
|
||||
run_desktop_stream(app_handle, $event_name, request, $runner).await
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Claude CLI commands (desktop) ───────────────────────────────────────────
|
||||
|
||||
@@ -12,35 +57,45 @@ pub fn check_claude_cli() -> ClaudeCliStatus {
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_claude_chat(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: ChatStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
crate::ai_agents::get_ai_agents_status()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_vault_ai_guidance_status(vault_path: String) -> Result<VaultAiGuidanceStatus, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::vault::get_ai_guidance_status(vault_path.as_ref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restore_vault_ai_guidance(vault_path: String) -> Result<VaultAiGuidanceStatus, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::vault::restore_ai_guidance_files(vault_path.as_ref())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_claude_agent(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: AgentStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-agent-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
define_desktop_stream_command!(
|
||||
stream_claude_chat,
|
||||
ChatStreamRequest,
|
||||
"claude-stream",
|
||||
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,
|
||||
AiAgentStreamRequest,
|
||||
"ai-agent-stream",
|
||||
crate::ai_agents::run_ai_agent_stream
|
||||
);
|
||||
|
||||
// ── Claude CLI (mobile stubs) ───────────────────────────────────────────────
|
||||
|
||||
@@ -53,6 +108,21 @@ pub fn check_claude_cli() -> ClaudeCliStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
AiAgentsStatus {
|
||||
claude_code: crate::ai_agents::AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
codex: crate::ai_agents::AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_claude_chat(
|
||||
@@ -70,3 +140,12 @@ pub async fn stream_claude_agent(
|
||||
) -> Result<String, String> {
|
||||
Err("Claude CLI is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_ai_agent(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_request: AiAgentStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
Err("CLI AI agents are not available on mobile".into())
|
||||
}
|
||||
|
||||
14
src-tauri/src/commands/delete.rs
Normal file
14
src-tauri/src/commands/delete.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use crate::vault;
|
||||
|
||||
use super::expand_tilde;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn batch_delete_notes_async(paths: Vec<String>) -> Result<Vec<String>, String> {
|
||||
let expanded: Vec<String> = paths
|
||||
.iter()
|
||||
.map(|path| expand_tilde(path).into_owned())
|
||||
.collect();
|
||||
tokio::task::spawn_blocking(move || vault::batch_delete_notes(&expanded))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
@@ -151,6 +151,13 @@ pub fn init_git_repo(vault_path: String) -> Result<(), String> {
|
||||
crate::git::init_repo(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(url: String, local_path: String) -> Result<String, String> {
|
||||
let local_path = expand_tilde(&local_path);
|
||||
crate::git::clone_repo(&url, &local_path)
|
||||
}
|
||||
|
||||
// ── Git commands (mobile stubs) ─────────────────────────────────────────────
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -271,3 +278,9 @@ pub fn is_git_repo(_vault_path: String) -> bool {
|
||||
pub fn init_git_repo(_vault_path: String) -> 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> {
|
||||
Err("Git clone is not available on mobile".into())
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
|
||||
use super::expand_tilde;
|
||||
|
||||
// ── GitHub commands (desktop) ───────────────────────────────────────────────
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
crate::github::github_list_repos(&token).await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_create_repo(
|
||||
token: String,
|
||||
name: String,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
crate::github::github_create_repo(&token, &name, private).await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
|
||||
let local_path = expand_tilde(&local_path);
|
||||
crate::github::clone_repo(&url, &token, &local_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
crate::github::github_device_flow_start().await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
|
||||
crate::github::github_device_flow_poll(&device_code).await
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn github_get_user(token: String) -> Result<GitHubUser, String> {
|
||||
crate::github::github_get_user(&token).await
|
||||
}
|
||||
|
||||
// ── GitHub commands (mobile stubs) ──────────────────────────────────────────
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_list_repos(_token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_create_repo(
|
||||
_token: String,
|
||||
_name: String,
|
||||
_private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(_url: String, _token: String, _local_path: String) -> Result<String, String> {
|
||||
Err("Git clone is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_poll(_device_code: String) -> Result<DeviceFlowPollResult, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn github_get_user(_token: String) -> Result<GitHubUser, String> {
|
||||
Err("GitHub integration is not available on mobile".into())
|
||||
}
|
||||
@@ -1,39 +1,49 @@
|
||||
mod ai;
|
||||
mod delete;
|
||||
mod git;
|
||||
mod github;
|
||||
mod system;
|
||||
mod vault;
|
||||
mod version;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub use ai::*;
|
||||
pub use delete::*;
|
||||
pub use git::*;
|
||||
pub use github::*;
|
||||
pub use system::*;
|
||||
pub use vault::*;
|
||||
pub use version::*;
|
||||
|
||||
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
|
||||
/// Returns the original string unchanged if it doesn't start with `~` or if the
|
||||
/// home directory cannot be determined.
|
||||
pub fn expand_tilde(path: &str) -> Cow<'_, str> {
|
||||
if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(home.to_string_lossy().into_owned());
|
||||
}
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest));
|
||||
}
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Cow::Borrowed(path);
|
||||
};
|
||||
|
||||
match path {
|
||||
"~" => Cow::Owned(home.to_string_lossy().into_owned()),
|
||||
_ => path
|
||||
.strip_prefix("~/")
|
||||
.map(|rest| Cow::Owned(home.join(rest).to_string_lossy().into_owned()))
|
||||
.unwrap_or(Cow::Borrowed(path)),
|
||||
}
|
||||
Cow::Borrowed(path)
|
||||
}
|
||||
|
||||
pub fn parse_build_label(version: &str) -> String {
|
||||
fn is_numeric_version_part(part: &str) -> bool {
|
||||
!part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn is_legacy_build_version(minor: &str, patch: &str) -> bool {
|
||||
minor.len() >= 6 && is_numeric_version_part(minor) && is_numeric_version_part(patch)
|
||||
}
|
||||
|
||||
fn parse_legacy_build_label(version: &str) -> Option<String> {
|
||||
let parts: Vec<&str> = version.split('.').collect();
|
||||
match parts.as_slice() {
|
||||
[_, minor, patch] if minor.len() >= 6 => format!("b{}", patch),
|
||||
[_, _, _] => "dev".to_string(),
|
||||
_ => "b?".to_string(),
|
||||
[_, minor, patch] if is_legacy_build_version(minor, patch) => Some(format!("b{}", patch)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,22 +82,4 @@ mod tests {
|
||||
let result = expand_tilde("/home/~user/path");
|
||||
assert_eq!(result, "/home/~user/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_release_version() {
|
||||
assert_eq!(parse_build_label("0.20260303.281"), "b281");
|
||||
assert_eq!(parse_build_label("0.20251215.42"), "b42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_dev_version() {
|
||||
assert_eq!(parse_build_label("0.1.0"), "dev");
|
||||
assert_eq!(parse_build_label("0.0.0"), "dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_malformed() {
|
||||
assert_eq!(parse_build_label("invalid"), "b?");
|
||||
assert_eq!(parse_build_label(""), "b?");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ use crate::settings::Settings;
|
||||
use crate::vault_list;
|
||||
use crate::vault_list::VaultList;
|
||||
use serde::Deserialize;
|
||||
#[cfg(desktop)]
|
||||
use tauri::ipc::Channel;
|
||||
#[cfg(desktop)]
|
||||
use tauri::LogicalSize;
|
||||
#[cfg(desktop)]
|
||||
use tauri::Window;
|
||||
|
||||
use super::parse_build_label;
|
||||
|
||||
@@ -91,6 +97,50 @@ pub fn trigger_menu_command(_app_handle: tauri::AppHandle, _id: String) -> Resul
|
||||
Err("Native menu commands are not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn update_current_window_min_size(
|
||||
window: Window,
|
||||
min_width: f64,
|
||||
min_height: f64,
|
||||
grow_to_fit: bool,
|
||||
) -> Result<(), String> {
|
||||
window
|
||||
.set_min_size(Some(LogicalSize::new(min_width, min_height)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !grow_to_fit {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let scale_factor = window.scale_factor().map_err(|e| e.to_string())?;
|
||||
let current_size = window
|
||||
.inner_size()
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_logical::<f64>(scale_factor);
|
||||
|
||||
let next_width = current_size.width.max(min_width);
|
||||
let next_height = current_size.height.max(min_height);
|
||||
if next_width == current_size.width && next_height == current_size.height {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
window
|
||||
.set_size(LogicalSize::new(next_width, next_height))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn update_current_window_min_size(
|
||||
_window: tauri::Window,
|
||||
_min_width: f64,
|
||||
_min_height: f64,
|
||||
_grow_to_fit: bool,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Settings & config commands ──────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
@@ -109,6 +159,52 @@ pub fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
crate::settings::save_settings(settings)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn check_for_app_update(
|
||||
app_handle: tauri::AppHandle,
|
||||
release_channel: Option<String>,
|
||||
) -> Result<Option<crate::app_updater::AppUpdateMetadata>, String> {
|
||||
crate::app_updater::check_for_app_update(app_handle, release_channel).await
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn check_for_app_update(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_release_channel: Option<String>,
|
||||
) -> Result<Option<crate::app_updater::AppUpdateMetadata>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn download_and_install_app_update(
|
||||
app_handle: tauri::AppHandle,
|
||||
release_channel: Option<String>,
|
||||
expected_version: String,
|
||||
on_event: Channel<crate::app_updater::AppUpdateDownloadEvent>,
|
||||
) -> Result<(), String> {
|
||||
crate::app_updater::download_and_install_app_update(
|
||||
app_handle,
|
||||
release_channel,
|
||||
expected_version,
|
||||
on_event,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn download_and_install_app_update(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_release_channel: Option<String>,
|
||||
_expected_version: String,
|
||||
_on_event: tauri::ipc::Channel<crate::app_updater::AppUpdateDownloadEvent>,
|
||||
) -> Result<(), String> {
|
||||
Err("App updates are not available on mobile".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn reinit_telemetry() {
|
||||
crate::telemetry::reinit_sentry();
|
||||
|
||||
@@ -102,40 +102,62 @@ pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
|
||||
#[tauri::command]
|
||||
pub fn create_vault_folder(vault_path: String, folder_name: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let folder_path = std::path::Path::new(vault_path.as_ref()).join(&folder_name);
|
||||
let folder_path =
|
||||
build_vault_folder_path(std::path::Path::new(vault_path.as_ref()), &folder_name);
|
||||
ensure_missing_folder(&folder_path, &folder_name)?;
|
||||
std::fs::create_dir_all(&folder_path).map_err(|e| format!("Failed to create folder: {}", e))?;
|
||||
Ok(folder_name)
|
||||
}
|
||||
|
||||
fn build_vault_folder_path(vault_root: &std::path::Path, folder_name: &str) -> std::path::PathBuf {
|
||||
vault_root.join(folder_name)
|
||||
}
|
||||
|
||||
fn ensure_missing_folder(folder_path: &std::path::Path, folder_name: &str) -> Result<(), String> {
|
||||
if folder_path.exists() {
|
||||
return Err(format!("Folder '{}' already exists", folder_name));
|
||||
}
|
||||
std::fs::create_dir_all(&folder_path).map_err(|e| format!("Failed to create folder: {}", e))?;
|
||||
Ok(folder_name)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Result<(), String> {
|
||||
std::fs::create_dir_all(vault_dir)
|
||||
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
|
||||
|
||||
git::init_repo(vault_path)?;
|
||||
vault::seed_config_files(vault_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_vault_path_string(vault_dir: &std::path::Path) -> String {
|
||||
vault_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| vault_dir.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_empty_vault(target_path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&target_path).into_owned();
|
||||
let vault_dir = std::path::Path::new(&path);
|
||||
|
||||
std::fs::create_dir_all(vault_dir)
|
||||
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
|
||||
|
||||
crate::git::init_repo(&path)?;
|
||||
|
||||
Ok(vault_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| vault_dir.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string())
|
||||
initialize_empty_vault(vault_dir, &path)?;
|
||||
Ok(canonical_vault_path_string(vault_dir))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
|
||||
let path = match target_path {
|
||||
Some(p) if !p.is_empty() => expand_tilde(&p).into_owned(),
|
||||
_ => vault::default_vault_path()?.to_string_lossy().to_string(),
|
||||
};
|
||||
let path = resolve_getting_started_target(target_path.as_deref())?;
|
||||
vault::create_getting_started_vault(&path)
|
||||
}
|
||||
|
||||
fn resolve_getting_started_target(target_path: Option<&str>) -> Result<String, String> {
|
||||
match target_path {
|
||||
Some(path) if !path.is_empty() => Ok(expand_tilde(path).into_owned()),
|
||||
_ => vault::default_vault_path().map(|path| path.to_string_lossy().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_vault_exists(path: String) -> bool {
|
||||
let path = expand_tilde(&path);
|
||||
@@ -409,4 +431,20 @@ mod tests {
|
||||
assert!(vault_path.join("config.md").exists());
|
||||
assert!(vault_path.join(".gitignore").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_empty_vault_seeds_agents_and_config() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("fresh-vault");
|
||||
|
||||
let result = create_empty_vault(vault_path.to_string_lossy().to_string());
|
||||
assert!(result.is_ok());
|
||||
assert!(vault_path.join(".git").exists());
|
||||
assert!(vault_path.join("AGENTS.md").exists());
|
||||
assert!(vault_path.join("config.md").exists());
|
||||
|
||||
let agents = std::fs::read_to_string(vault_path.join("AGENTS.md")).unwrap();
|
||||
assert!(agents.contains("Legacy `title:` frontmatter is still read as a fallback"));
|
||||
assert!(agents.contains("views/*.yml"));
|
||||
}
|
||||
}
|
||||
|
||||
121
src-tauri/src/commands/version.rs
Normal file
121
src-tauri/src/commands/version.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use super::{is_numeric_version_part, parse_legacy_build_label};
|
||||
|
||||
pub fn parse_build_label(version: &str) -> String {
|
||||
let version = version.trim();
|
||||
if version.is_empty() {
|
||||
return "b?".to_string();
|
||||
}
|
||||
|
||||
parse_legacy_build_label(version)
|
||||
.or_else(|| parse_calendar_build_label(version))
|
||||
.or_else(|| parse_semver_build_label(version))
|
||||
.unwrap_or_else(|| "b?".to_string())
|
||||
}
|
||||
|
||||
fn strip_build_metadata(version: &str) -> &str {
|
||||
version.split_once('+').map_or(version, |(base, _)| base)
|
||||
}
|
||||
|
||||
fn parse_calendar_build_label(version: &str) -> Option<String> {
|
||||
let semver = strip_build_metadata(version);
|
||||
let (core, prerelease) = semver
|
||||
.split_once('-')
|
||||
.map_or((semver, None), |(base, suffix)| (base, Some(suffix)));
|
||||
let [year, month, day] = split_numeric_version_parts(core)?;
|
||||
if year.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let core_version = format!(
|
||||
"{}.{}.{}",
|
||||
year.parse::<u32>().ok()?,
|
||||
month.parse::<u32>().ok()?,
|
||||
day.parse::<u32>().ok()?
|
||||
);
|
||||
|
||||
match prerelease {
|
||||
Some(suffix) if suffix.starts_with("alpha.") => suffix
|
||||
.strip_prefix("alpha.")
|
||||
.map(|sequence| format!("Alpha {}.{}", core_version, sequence)),
|
||||
Some(suffix) if suffix.starts_with("stable.") => Some(core_version),
|
||||
Some(_) => None,
|
||||
None => Some(core_version),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_semver_build_label(version: &str) -> Option<String> {
|
||||
let semver = strip_build_metadata(version);
|
||||
let (core, prerelease) = semver
|
||||
.split_once('-')
|
||||
.map_or((semver, None), |(base, suffix)| (base, Some(suffix)));
|
||||
split_numeric_version_parts(core)?;
|
||||
|
||||
match prerelease {
|
||||
Some(suffix) if suffix.starts_with("alpha.") => Some(format!("Alpha {}", semver)),
|
||||
Some(_) => Some(format!("v{}", semver)),
|
||||
None if semver == "0.1.0" || semver == "0.0.0" => Some("dev".to_string()),
|
||||
None => Some(format!("v{}", semver)),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_numeric_version_parts(version: &str) -> Option<[&str; 3]> {
|
||||
let parts: Vec<&str> = version.split('.').collect();
|
||||
let [major, minor, patch] = parts.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
if ![major, minor, patch]
|
||||
.iter()
|
||||
.all(|part| is_numeric_version_part(part))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some([major, minor, patch])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_build_label;
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_release_version() {
|
||||
assert_eq!(parse_build_label("0.20260303.281"), "b281");
|
||||
assert_eq!(parse_build_label("0.20251215.42"), "b42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_calendar_versions() {
|
||||
assert_eq!(parse_build_label("2026.4.16"), "2026.4.16");
|
||||
assert_eq!(parse_build_label("2026.4.16-stable.1"), "2026.4.16");
|
||||
assert_eq!(parse_build_label("2026.4.16-alpha.3"), "Alpha 2026.4.16.3");
|
||||
assert_eq!(
|
||||
parse_build_label("2026.4.16-alpha.3+darwin"),
|
||||
"Alpha 2026.4.16.3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_legacy_semver_releases() {
|
||||
assert_eq!(parse_build_label("1.2.3"), "v1.2.3");
|
||||
assert_eq!(
|
||||
parse_build_label("1.2.4-alpha.202604122135.7"),
|
||||
"Alpha 1.2.4-alpha.202604122135.7"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_build_label("1.2.4-alpha.202604122135.7+darwin"),
|
||||
"Alpha 1.2.4-alpha.202604122135.7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_dev_version() {
|
||||
assert_eq!(parse_build_label("0.1.0"), "dev");
|
||||
assert_eq!(parse_build_label("0.0.0"), "dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_malformed() {
|
||||
assert_eq!(parse_build_label("invalid"), "b?");
|
||||
assert_eq!(parse_build_label(""), "b?");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
mod ops;
|
||||
#[cfg(test)]
|
||||
mod ops_update_tests;
|
||||
mod yaml;
|
||||
|
||||
use std::fs;
|
||||
@@ -132,32 +134,36 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_add() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\n## Timeline";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("template: |"));
|
||||
assert!(updated.contains(" ## Objective"));
|
||||
assert!(updated.contains("type: Type"));
|
||||
}
|
||||
fn test_update_frontmatter_block_scalar_writes_and_rewrites() {
|
||||
let cases = [
|
||||
(
|
||||
"---\ntype: Type\n---\n# Project\n",
|
||||
"## Objective\n\n## Timeline",
|
||||
&["template: |", " ## Objective", "type: Type"][..],
|
||||
&[][..],
|
||||
),
|
||||
(
|
||||
"---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n",
|
||||
"## New\n\n## Content",
|
||||
&[" ## New", "color: green"][..],
|
||||
&["## Old"][..],
|
||||
),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_replace() {
|
||||
let content = "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String("## New\n\n## Content".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" ## New"));
|
||||
assert!(!updated.contains("## Old"));
|
||||
assert!(updated.contains("color: green"));
|
||||
for (content, template, expected_present, expected_absent) in cases {
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
for expected in expected_present {
|
||||
assert!(updated.contains(expected));
|
||||
}
|
||||
for unexpected in expected_absent {
|
||||
assert!(!updated.contains(unexpected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,85 +1,152 @@
|
||||
use super::yaml::{format_yaml_field, FrontmatterValue};
|
||||
|
||||
/// Check if a line defines a specific key (handles quoted and unquoted keys)
|
||||
fn line_is_key(line: &str, key: &str) -> bool {
|
||||
let trimmed = line.trim_start();
|
||||
|
||||
if trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':') {
|
||||
return true;
|
||||
}
|
||||
|
||||
let dq = format!("\"{}\":", key);
|
||||
if trimmed.starts_with(&dq) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let sq = format!("'{}\':", key);
|
||||
if trimmed.starts_with(&sq) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a line continues the previous key's value (indented list item,
|
||||
/// block scalar content, or blank line inside a block scalar).
|
||||
fn is_value_continuation(line: &str) -> bool {
|
||||
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
|
||||
fn is_value_continuation(line: FrontmatterLine<'_>) -> bool {
|
||||
line.0.is_empty() || line.0.starts_with(" ") || line.0.starts_with('\t')
|
||||
}
|
||||
|
||||
/// Split content into frontmatter body and the rest after the closing `---`.
|
||||
/// Returns `(fm_content, rest)` where `fm_content` is between the opening and closing `---`.
|
||||
fn split_frontmatter(content: &str) -> Result<(&str, &str), String> {
|
||||
let after_open = &content[4..];
|
||||
// Handle empty frontmatter: closing --- immediately after opening ---\n
|
||||
if let Some(stripped) = after_open.strip_prefix("---") {
|
||||
return Ok(("", stripped));
|
||||
#[derive(Clone, Copy)]
|
||||
enum SystemKey {
|
||||
Icon,
|
||||
Order,
|
||||
SidebarLabel,
|
||||
Sort,
|
||||
}
|
||||
|
||||
impl SystemKey {
|
||||
fn canonical(self) -> &'static str {
|
||||
match self {
|
||||
Self::Icon => "_icon",
|
||||
Self::Order => "_order",
|
||||
Self::SidebarLabel => "_sidebar_label",
|
||||
Self::Sort => "_sort",
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_aliases(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Icon => &["icon"],
|
||||
Self::Order => &["order"],
|
||||
Self::SidebarLabel => &["sidebar_label", "sidebar label"],
|
||||
Self::Sort => &["sort"],
|
||||
}
|
||||
}
|
||||
let fm_end = after_open
|
||||
.find("\n---")
|
||||
.map(|i| i + 4)
|
||||
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
|
||||
Ok((&content[4..fm_end], &content[fm_end + 4..]))
|
||||
}
|
||||
|
||||
/// Wrap content in a new frontmatter block containing a single field.
|
||||
fn prepend_new_frontmatter(content: &str, key: &str, value: &FrontmatterValue) -> String {
|
||||
let field_lines = format_yaml_field(key, value);
|
||||
format!("---\n{}\n---\n{}", field_lines.join("\n"), content)
|
||||
#[derive(Clone, Copy)]
|
||||
struct DocumentText<'a>(&'a str);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct FrontmatterLine<'a>(&'a str);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PropertyKey<'a>(&'a str);
|
||||
|
||||
impl<'a> PropertyKey<'a> {
|
||||
fn as_str(self) -> &'a str {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a field update to existing frontmatter lines.
|
||||
/// Replaces the matching key (and its list continuations) with the new value,
|
||||
/// or appends if the key is not found. If `value` is None, removes the key.
|
||||
fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue>) -> Vec<String> {
|
||||
let mut new_lines: Vec<String> = Vec::new();
|
||||
let mut found_key = false;
|
||||
let mut i = 0;
|
||||
#[derive(Clone, Copy)]
|
||||
struct FieldUpdate<'a> {
|
||||
key: PropertyKey<'a>,
|
||||
value: Option<&'a FrontmatterValue>,
|
||||
}
|
||||
|
||||
while i < lines.len() {
|
||||
if !line_is_key(lines[i], key) {
|
||||
new_lines.push(lines[i].to_string());
|
||||
impl<'a> FieldUpdate<'a> {
|
||||
fn matches_line(self, line: FrontmatterLine<'_>) -> bool {
|
||||
let trimmed = line.0.trim_start();
|
||||
|
||||
if trimmed.starts_with(self.key.as_str())
|
||||
&& trimmed[self.key.as_str().len()..].starts_with(':')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let double_quoted = format!("\"{}\":", self.key.as_str());
|
||||
if trimmed.starts_with(&double_quoted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let single_quoted = format!("'{}\':", self.key.as_str());
|
||||
trimmed.starts_with(&single_quoted)
|
||||
}
|
||||
|
||||
fn prepend_to(self, content: DocumentText<'_>) -> String {
|
||||
let field_lines =
|
||||
format_yaml_field(self.key.as_str(), self.value.expect("value must exist"));
|
||||
format!("---\n{}\n---\n{}", field_lines.join("\n"), content.0)
|
||||
}
|
||||
|
||||
fn apply_to_lines(self, lines: &[FrontmatterLine<'_>]) -> Vec<String> {
|
||||
let mut new_lines: Vec<String> = Vec::new();
|
||||
let mut found_key = false;
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
if !self.matches_line(lines[i]) {
|
||||
new_lines.push(lines[i].0.to_string());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
found_key = true;
|
||||
i += 1;
|
||||
continue;
|
||||
while i < lines.len() && is_value_continuation(lines[i]) {
|
||||
i += 1;
|
||||
}
|
||||
if let Some(v) = self.value {
|
||||
new_lines.extend(format_yaml_field(self.key.as_str(), v));
|
||||
}
|
||||
}
|
||||
|
||||
found_key = true;
|
||||
i += 1;
|
||||
// Skip continuation lines belonging to this key (lists, block scalars)
|
||||
while i < lines.len() && is_value_continuation(lines[i]) {
|
||||
i += 1;
|
||||
}
|
||||
// Insert replacement value (if any)
|
||||
if let Some(v) = value {
|
||||
new_lines.extend(format_yaml_field(key, v));
|
||||
if let (false, Some(v)) = (found_key, self.value) {
|
||||
new_lines.extend(format_yaml_field(self.key.as_str(), v));
|
||||
}
|
||||
|
||||
new_lines
|
||||
}
|
||||
|
||||
if let (false, Some(v)) = (found_key, value) {
|
||||
new_lines.extend(format_yaml_field(key, v));
|
||||
}
|
||||
fn apply_to_content(self, content: DocumentText<'_>) -> Result<String, String> {
|
||||
if !content.0.starts_with("---\n") {
|
||||
return match self.value {
|
||||
Some(_) => Ok(self.prepend_to(content)),
|
||||
None => Ok(content.0.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
new_lines
|
||||
let after_open = &content.0[4..];
|
||||
let (fm_content, rest) = if let Some(stripped) = after_open.strip_prefix("---") {
|
||||
("", stripped)
|
||||
} else {
|
||||
let fm_end = after_open
|
||||
.find("\n---")
|
||||
.map(|i| i + 4)
|
||||
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
|
||||
(&content.0[4..fm_end], &content.0[fm_end + 4..])
|
||||
};
|
||||
let lines: Vec<FrontmatterLine<'_>> = fm_content.lines().map(FrontmatterLine).collect();
|
||||
let new_fm = self.apply_to_lines(&lines).join("\n");
|
||||
Ok(format!("---\n{}\n---{}", new_fm, rest))
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_system_key(key: PropertyKey<'_>) -> Option<SystemKey> {
|
||||
match key
|
||||
.as_str()
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.replace(' ', "_")
|
||||
.as_str()
|
||||
{
|
||||
"_icon" | "icon" => Some(SystemKey::Icon),
|
||||
"_order" | "order" => Some(SystemKey::Order),
|
||||
"_sidebar_label" | "sidebar_label" | "sidebar label" => Some(SystemKey::SidebarLabel),
|
||||
"_sort" | "sort" => Some(SystemKey::Sort),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal function to update frontmatter content
|
||||
@@ -88,244 +155,26 @@ pub fn update_frontmatter_content(
|
||||
key: &str,
|
||||
value: Option<FrontmatterValue>,
|
||||
) -> Result<String, String> {
|
||||
if !content.starts_with("---\n") {
|
||||
return match value {
|
||||
Some(v) => Ok(prepend_new_frontmatter(content, key, &v)),
|
||||
None => Ok(content.to_string()),
|
||||
};
|
||||
let update = FieldUpdate {
|
||||
key: PropertyKey(key),
|
||||
value: value.as_ref(),
|
||||
};
|
||||
let Some(system_key) = canonical_system_key(update.key) else {
|
||||
return update.apply_to_content(DocumentText(content));
|
||||
};
|
||||
|
||||
let mut updated = content.to_string();
|
||||
for alias in system_key.legacy_aliases() {
|
||||
updated = FieldUpdate {
|
||||
key: PropertyKey(alias),
|
||||
value: None,
|
||||
}
|
||||
.apply_to_content(DocumentText(&updated))?;
|
||||
}
|
||||
|
||||
let (fm_content, rest) = split_frontmatter(content)?;
|
||||
let lines: Vec<&str> = fm_content.lines().collect();
|
||||
let new_lines = apply_field_update(&lines, key, value.as_ref());
|
||||
let new_fm = new_lines.join("\n");
|
||||
Ok(format!("---\n{}\n---{}", new_fm, rest))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Status: Active"));
|
||||
assert!(!updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_add_new_key() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Owner: Luca"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_quoted_key() {
|
||||
let content = "---\n\"Is A\": Note\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Is A",
|
||||
Some(FrontmatterValue::String("Project".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("\"Is A\": Project"));
|
||||
assert!(!updated.contains("Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec![
|
||||
"Alias1".to_string(),
|
||||
"Alias2".to_string(),
|
||||
])),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("aliases:"));
|
||||
assert!(updated.contains(" - \"Alias1\""));
|
||||
assert!(updated.contains(" - \"Alias2\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_replace_list() {
|
||||
let content = "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec!["New1".to_string()])),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" - \"New1\""));
|
||||
assert!(!updated.contains("Old1"));
|
||||
assert!(!updated.contains("Old2"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_property() {
|
||||
let content = "---\nStatus: Draft\nOwner: Luca\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "Owner", None).unwrap();
|
||||
assert!(!updated.contains("Owner"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_list_property() {
|
||||
let content = "---\naliases:\n - Alias1\n - Alias2\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "aliases", None).unwrap();
|
||||
assert!(!updated.contains("aliases"));
|
||||
assert!(!updated.contains("Alias1"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_existing() {
|
||||
let content = "# Test\n\nSome content here.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Draft".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.starts_with("---\n"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
assert!(updated.contains("# Test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_bool() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Reviewed", Some(FrontmatterValue::Bool(true)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Reviewed: true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_number() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Priority", Some(FrontmatterValue::Number(5.0)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Priority: 5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_number_float() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Score", Some(FrontmatterValue::Number(9.5)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Score: 9.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_null() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "ClearMe", Some(FrontmatterValue::Null)).unwrap();
|
||||
assert!(updated.contains("ClearMe: null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_empty_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "tags", Some(FrontmatterValue::List(vec![])))
|
||||
.unwrap();
|
||||
assert!(updated.contains("tags: []"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_malformed_no_closing_fence() {
|
||||
let content = "---\nStatus: Draft\nNo closing fence here";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Malformed frontmatter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_key_noop() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_from_no_frontmatter_noop() {
|
||||
let content = "# Test\n\nSome content.";
|
||||
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_unquoted() {
|
||||
assert!(line_is_key("Status: Draft", "Status"));
|
||||
assert!(!line_is_key("Status: Draft", "Owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_double_quoted() {
|
||||
assert!(line_is_key("\"Is A\": Note", "Is A"));
|
||||
assert!(!line_is_key("\"Is A\": Note", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_single_quoted() {
|
||||
assert!(line_is_key("'Is A': Note", "Is A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_leading_whitespace() {
|
||||
assert!(line_is_key(" Status: Draft", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_partial_match() {
|
||||
assert!(!line_is_key("StatusBar: value", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block() {
|
||||
let result = split_frontmatter("---\n---\n");
|
||||
assert!(result.is_ok());
|
||||
let (fm, rest) = result.unwrap();
|
||||
assert_eq!(fm, "");
|
||||
assert_eq!(rest, "\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block_no_trailing_newline() {
|
||||
let result = split_frontmatter("---\n---");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block_with_body() {
|
||||
let result = split_frontmatter("---\n---\n\n# Title\n");
|
||||
assert!(result.is_ok());
|
||||
let (fm, rest) = result.unwrap();
|
||||
assert_eq!(fm, "");
|
||||
assert!(rest.contains("# Title"));
|
||||
}
|
||||
FieldUpdate {
|
||||
key: PropertyKey(system_key.canonical()),
|
||||
value: update.value,
|
||||
}
|
||||
.apply_to_content(DocumentText(&updated))
|
||||
}
|
||||
|
||||
192
src-tauri/src/frontmatter/ops_update_tests.rs
Normal file
192
src-tauri/src/frontmatter/ops_update_tests.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
use super::{update_frontmatter_content, FrontmatterValue};
|
||||
|
||||
struct UpdateCase<'a> {
|
||||
content: &'a str,
|
||||
key: &'a str,
|
||||
value: Option<FrontmatterValue>,
|
||||
expected_present: &'a [&'a str],
|
||||
expected_absent: &'a [&'a str],
|
||||
}
|
||||
|
||||
fn assert_updated_content(case: UpdateCase<'_>) {
|
||||
let updated = update_frontmatter_content(case.content, case.key, case.value).unwrap();
|
||||
for expected in case.expected_present {
|
||||
assert!(
|
||||
updated.contains(expected),
|
||||
"missing expected snippet: {expected}"
|
||||
);
|
||||
}
|
||||
for unexpected in case.expected_absent {
|
||||
assert!(
|
||||
!updated.contains(unexpected),
|
||||
"found unexpected snippet: {unexpected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_replaces_or_adds_scalar_fields() {
|
||||
let cases = [
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "Status",
|
||||
value: Some(FrontmatterValue::String("Active".to_string())),
|
||||
expected_present: &["Status: Active"],
|
||||
expected_absent: &["Status: Draft"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "Owner",
|
||||
value: Some(FrontmatterValue::String("Luca".to_string())),
|
||||
expected_present: &["Owner: Luca", "Status: Draft"],
|
||||
expected_absent: &[],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\n\"Is A\": Note\n---\n# Test\n",
|
||||
key: "Is A",
|
||||
value: Some(FrontmatterValue::String("Project".to_string())),
|
||||
expected_present: &["\"Is A\": Project"],
|
||||
expected_absent: &["\"Is A\": Note"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "Reviewed",
|
||||
value: Some(FrontmatterValue::Bool(true)),
|
||||
expected_present: &["Reviewed: true"],
|
||||
expected_absent: &[],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "Priority",
|
||||
value: Some(FrontmatterValue::Number(5.0)),
|
||||
expected_present: &["Priority: 5"],
|
||||
expected_absent: &[],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "Score",
|
||||
value: Some(FrontmatterValue::Number(9.5)),
|
||||
expected_present: &["Score: 9.5"],
|
||||
expected_absent: &[],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "ClearMe",
|
||||
value: Some(FrontmatterValue::Null),
|
||||
expected_present: &["ClearMe: null"],
|
||||
expected_absent: &[],
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
assert_updated_content(case);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_list_and_delete_paths() {
|
||||
let list_cases = [
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "aliases",
|
||||
value: Some(FrontmatterValue::List(vec![
|
||||
"Alias1".to_string(),
|
||||
"Alias2".to_string(),
|
||||
])),
|
||||
expected_present: &["aliases:", " - \"Alias1\"", " - \"Alias2\""],
|
||||
expected_absent: &[],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n",
|
||||
key: "aliases",
|
||||
value: Some(FrontmatterValue::List(vec!["New1".to_string()])),
|
||||
expected_present: &[" - \"New1\"", "Status: Draft"],
|
||||
expected_absent: &["Old1", "Old2"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\naliases:\n - Alias1\n - Alias2\nStatus: Draft\n---\n# Test\n",
|
||||
key: "aliases",
|
||||
value: None,
|
||||
expected_present: &["Status: Draft"],
|
||||
expected_absent: &["aliases", "Alias1"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\nOwner: Luca\n---\n# Test\n",
|
||||
key: "Owner",
|
||||
value: None,
|
||||
expected_present: &["Status: Draft"],
|
||||
expected_absent: &["Owner"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
key: "tags",
|
||||
value: Some(FrontmatterValue::List(vec![])),
|
||||
expected_present: &["tags: []"],
|
||||
expected_absent: &[],
|
||||
},
|
||||
];
|
||||
|
||||
for case in list_cases {
|
||||
assert_updated_content(case);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_handles_missing_or_malformed_frontmatter() {
|
||||
let inserted = update_frontmatter_content(
|
||||
"# Test\n\nSome content here.",
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Draft".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(inserted.starts_with("---\n"));
|
||||
assert!(inserted.contains("Status: Draft"));
|
||||
assert!(inserted.contains("# Test"));
|
||||
|
||||
let malformed = update_frontmatter_content(
|
||||
"---\nStatus: Draft\nNo closing fence here",
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
);
|
||||
assert!(malformed.is_err());
|
||||
assert!(malformed.unwrap_err().contains("Malformed frontmatter"));
|
||||
|
||||
let unchanged =
|
||||
update_frontmatter_content("---\nStatus: Draft\n---\n# Test\n", "Missing", None).unwrap();
|
||||
assert_eq!(unchanged, "---\nStatus: Draft\n---\n# Test\n");
|
||||
|
||||
let no_frontmatter =
|
||||
update_frontmatter_content("# Test\n\nSome content.", "Missing", None).unwrap();
|
||||
assert_eq!(no_frontmatter, "# Test\n\nSome content.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_canonicalizes_system_metadata_keys() {
|
||||
let cases = [
|
||||
UpdateCase {
|
||||
content: "---\nicon: rocket\n---\n# Test\n",
|
||||
key: "icon",
|
||||
value: Some(FrontmatterValue::String("star".to_string())),
|
||||
expected_present: &["_icon: star"],
|
||||
expected_absent: &["\nicon:", "rocket"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nsidebar label: Projects\nsidebar_label: Legacy\n---\n# Test\n",
|
||||
key: "_sidebar_label",
|
||||
value: Some(FrontmatterValue::String("Programs".to_string())),
|
||||
expected_present: &["_sidebar_label: Programs"],
|
||||
expected_absent: &["sidebar label: Projects", "sidebar_label: Legacy"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nsort: modified:desc\n_sort: title:asc\n---\n# Test\n",
|
||||
key: "_sort",
|
||||
value: None,
|
||||
expected_present: &["# Test"],
|
||||
expected_absent: &["\nsort:", "\n_sort:"],
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
assert_updated_content(case);
|
||||
}
|
||||
}
|
||||
163
src-tauri/src/git/clone.rs
Normal file
163
src-tauri/src/git/clone.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// 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);
|
||||
prepare_clone_destination(dest)?;
|
||||
|
||||
if let Err(err) = run_clone(url, dest) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(format!("Cloned to {}", dest.display()))
|
||||
}
|
||||
|
||||
fn prepare_clone_destination(dest: &Path) -> Result<(), String> {
|
||||
if !dest.exists() {
|
||||
return ensure_parent_directory(dest);
|
||||
}
|
||||
|
||||
ensure_empty_directory(dest)
|
||||
}
|
||||
|
||||
fn ensure_empty_directory(dest: &Path) -> Result<(), String> {
|
||||
if !dest.is_dir() {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not a directory",
|
||||
dest.display()
|
||||
));
|
||||
}
|
||||
|
||||
if directory_has_entries(dest)? {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not empty",
|
||||
dest.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_parent_directory(dest: &Path) -> Result<(), String> {
|
||||
let Some(parent) = dest.parent() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if parent.as_os_str().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create parent directory for '{}': {}",
|
||||
dest.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn directory_has_entries(dest: &Path) -> Result<bool, String> {
|
||||
dest.read_dir()
|
||||
.map_err(|e| format!("Failed to inspect destination '{}': {}", dest.display(), e))
|
||||
.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])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("git clone failed: {}", stderr.trim()))
|
||||
}
|
||||
|
||||
fn cleanup_failed_clone(dest: &Path) {
|
||||
if dest.exists() && dest.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
fn init_source_repo(path: &Path) {
|
||||
fs::create_dir_all(path).unwrap();
|
||||
fs::write(path.join("welcome.md"), "# Welcome\n").unwrap();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.email", "tolaria@app.local"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.name", "Tolaria App"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_clones_local_repository() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("source");
|
||||
let dest = dir.path().join("dest");
|
||||
init_source_repo(&source);
|
||||
|
||||
let result = clone_repo(source.to_str().unwrap(), dest.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(result, format!("Cloned to {}", dest.to_string_lossy()));
|
||||
assert!(dest.join(".git").exists());
|
||||
assert!(dest.join("welcome.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_nonempty_dest() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
fs::write(dir.path().join("existing.txt"), "data").unwrap();
|
||||
|
||||
let result = clone_repo("https://example.com/repo.git", dir.path().to_str().unwrap());
|
||||
assert!(result.unwrap_err().contains("not empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_empty_dest_allowed() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("empty-dir");
|
||||
fs::create_dir(&dest).unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://example.com/nonexistent/repo.git",
|
||||
dest.to_str().unwrap(),
|
||||
);
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod clone;
|
||||
mod commit;
|
||||
mod conflict;
|
||||
mod dates;
|
||||
@@ -9,6 +10,7 @@ mod status;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub use clone::clone_repo;
|
||||
pub use commit::git_commit;
|
||||
pub use conflict::{
|
||||
get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict,
|
||||
@@ -122,28 +124,27 @@ fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
/// Extract "owner/repo" from a GitHub remote URL.
|
||||
/// Supports HTTPS (https://github.com/owner/repo.git) and
|
||||
/// SSH (git@github.com:owner/repo.git) formats.
|
||||
fn normalize_github_repo_path(repo_path: &str) -> Option<String> {
|
||||
let repo_path = repo_path.strip_suffix(".git").unwrap_or(repo_path);
|
||||
repo_path.contains('/').then(|| repo_path.to_string())
|
||||
}
|
||||
|
||||
fn github_remote_suffix(url: &str) -> Option<&str> {
|
||||
const GITHUB_PREFIXES: [&str; 4] = [
|
||||
"git@github.com:",
|
||||
"https://github.com/",
|
||||
"http://github.com/",
|
||||
"ssh://git@github.com/",
|
||||
];
|
||||
|
||||
GITHUB_PREFIXES
|
||||
.iter()
|
||||
.find_map(|prefix| url.strip_prefix(prefix))
|
||||
.or_else(|| url.split_once("@github.com/").map(|(_, suffix)| suffix))
|
||||
}
|
||||
|
||||
fn parse_github_repo_path(url: &str) -> Option<String> {
|
||||
let trimmed = url.trim();
|
||||
|
||||
// SSH format: git@github.com:owner/repo.git
|
||||
if let Some(rest) = trimmed.strip_prefix("git@github.com:") {
|
||||
let path = rest.strip_suffix(".git").unwrap_or(rest);
|
||||
if path.contains('/') {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPS format: https://github.com/owner/repo.git
|
||||
// Also handle token-embedded URLs: https://token@github.com/owner/repo.git
|
||||
if trimmed.contains("github.com/") {
|
||||
let after = trimmed.split("github.com/").nth(1)?;
|
||||
let path = after.strip_suffix(".git").unwrap_or(after);
|
||||
if path.contains('/') {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
github_remote_suffix(url.trim()).and_then(normalize_github_repo_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -153,6 +154,13 @@ mod tests {
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn assert_repo_path(url: &str, expected: Option<&str>) {
|
||||
assert_eq!(
|
||||
parse_github_repo_path(url),
|
||||
expected.map(ToString::to_string)
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn setup_git_repo() -> TempDir {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
@@ -348,42 +356,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_https() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://github.com/owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://github.com/owner/repo"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_ssh() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("git@github.com:owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_github_repo_path("git@github.com:owner/repo"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_token_embedded() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://gho_abc123@github.com/owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
fn test_parse_github_repo_path_variants() {
|
||||
for url in [
|
||||
"https://github.com/owner/repo.git",
|
||||
"https://github.com/owner/repo",
|
||||
"http://github.com/owner/repo.git",
|
||||
"git@github.com:owner/repo.git",
|
||||
"git@github.com:owner/repo",
|
||||
"ssh://git@github.com/owner/repo.git",
|
||||
"https://gho_abc123@github.com/owner/repo.git",
|
||||
] {
|
||||
assert_repo_path(url, Some("owner/repo"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_non_github() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://gitlab.com/owner/repo.git"),
|
||||
None
|
||||
);
|
||||
assert_repo_path("https://gitlab.com/owner/repo.git", None);
|
||||
assert_repo_path("owner/repo", None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -8,23 +9,198 @@ pub struct ModifiedFile {
|
||||
#[serde(rename = "relativePath")]
|
||||
pub relative_path: String,
|
||||
pub status: String,
|
||||
#[serde(rename = "addedLines")]
|
||||
pub added_lines: Option<usize>,
|
||||
#[serde(rename = "deletedLines")]
|
||||
pub deleted_lines: Option<usize>,
|
||||
pub binary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct DiffStats {
|
||||
added_lines: Option<usize>,
|
||||
deleted_lines: Option<usize>,
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_status_path(raw_path: &str) -> String {
|
||||
raw_path
|
||||
.split(" -> ")
|
||||
.last()
|
||||
.unwrap_or(raw_path)
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn parse_numstat_field(field: &str) -> Option<usize> {
|
||||
field.parse().ok()
|
||||
}
|
||||
|
||||
fn parse_numstat_line(line: &str) -> Option<(String, DiffStats)> {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let added_lines = parse_numstat_field(parts[0]);
|
||||
let deleted_lines = parse_numstat_field(parts[1]);
|
||||
let binary = parts[0] == "-" || parts[1] == "-";
|
||||
|
||||
Some((
|
||||
parts.last()?.trim().to_string(),
|
||||
DiffStats {
|
||||
added_lines,
|
||||
deleted_lines,
|
||||
binary,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn repo_has_head(vault: &Path) -> Result<bool, String> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--verify", "HEAD"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git rev-parse: {e}"))?;
|
||||
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
||||
fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
|
||||
if !repo_has_head(vault)? {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["diff", "--numstat", "--find-renames", "HEAD", "--"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git diff --numstat: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git diff --numstat failed: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.filter_map(parse_numstat_line)
|
||||
.collect::<HashMap<_, _>>())
|
||||
}
|
||||
|
||||
fn count_worktree_lines(vault: &Path, relative_path: &str) -> DiffStats {
|
||||
let full_path = vault.join(relative_path);
|
||||
let added_lines = std::fs::read_to_string(full_path)
|
||||
.ok()
|
||||
.map(|content| content.lines().count());
|
||||
|
||||
DiffStats {
|
||||
added_lines,
|
||||
deleted_lines: None,
|
||||
binary: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_diff_stats(
|
||||
vault: &Path,
|
||||
relative_path: &str,
|
||||
status: &str,
|
||||
diff_stats: &HashMap<String, DiffStats>,
|
||||
) -> DiffStats {
|
||||
if status == "untracked" {
|
||||
return count_worktree_lines(vault, relative_path);
|
||||
}
|
||||
|
||||
diff_stats.get(relative_path).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() {
|
||||
if matches!(component, std::path::Component::ParentDir) {
|
||||
return Err("File path is outside the vault".into());
|
||||
}
|
||||
}
|
||||
|
||||
if !abs.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let canonical_vault = vault
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot resolve vault path: {e}"))?;
|
||||
let canonical_file = abs
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot resolve file path: {e}"))?;
|
||||
|
||||
if canonical_file.starts_with(&canonical_vault) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("File path is outside the vault".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn load_file_status(vault: &Path, relative_path: &str) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain", "--", relative_path])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
.lines()
|
||||
.find(|line| line.len() >= 4)
|
||||
.map(|line| line[..2].trim().to_string())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn restore_tracked_file(vault: &Path, relative_path: &str) -> Result<(), String> {
|
||||
let _ = Command::new("git")
|
||||
.args(["reset", "HEAD", "--", relative_path])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
let checkout = Command::new("git")
|
||||
.args(["checkout", "--", relative_path])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git checkout: {e}"))?;
|
||||
|
||||
if checkout.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&checkout.stderr);
|
||||
Err(format!("git checkout failed: {}", stderr.trim()))
|
||||
}
|
||||
|
||||
/// 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")
|
||||
.args(["status", "--porcelain", "--untracked-files=all"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {}", e))?;
|
||||
.map_err(|e| format!("Failed to run git status: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git status failed: {}", stderr));
|
||||
return Err(format!("git status failed: {}", stderr.trim()));
|
||||
}
|
||||
|
||||
let diff_stats = load_diff_stats(vault)?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let files = stdout
|
||||
.lines()
|
||||
@@ -34,28 +210,24 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
|
||||
return None;
|
||||
}
|
||||
let status_code = &line[..2];
|
||||
let path = line[3..].trim().to_string();
|
||||
let relative_path = parse_status_path(&line[3..]);
|
||||
|
||||
// Only include markdown files
|
||||
if !path.ends_with(".md") {
|
||||
if !relative_path.ends_with(".md") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = match status_code.trim() {
|
||||
"M" | "MM" | "AM" => "modified",
|
||||
"A" => "added",
|
||||
"D" => "deleted",
|
||||
"??" => "untracked",
|
||||
"R" | "RM" => "renamed",
|
||||
_ => "modified",
|
||||
};
|
||||
|
||||
let full_path = vault.join(&path).to_string_lossy().to_string();
|
||||
let status = status_label(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);
|
||||
|
||||
Some(ModifiedFile {
|
||||
path: full_path,
|
||||
relative_path: path,
|
||||
relative_path,
|
||||
status: status.to_string(),
|
||||
added_lines: stats.added_lines,
|
||||
deleted_lines: stats.deleted_lines,
|
||||
binary: stats.binary,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -74,61 +246,16 @@ pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(),
|
||||
let vault = Path::new(vault_path);
|
||||
let abs = vault.join(relative_path);
|
||||
|
||||
// Safety: ensure the resolved path stays inside the vault.
|
||||
// Safety: reject any relative_path that tries to escape the vault via `..`.
|
||||
for component in std::path::Path::new(relative_path).components() {
|
||||
if matches!(component, std::path::Component::ParentDir) {
|
||||
return Err("File path is outside the vault".into());
|
||||
}
|
||||
}
|
||||
if abs.exists() {
|
||||
let canonical_vault = vault
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot resolve vault path: {e}"))?;
|
||||
let canonical_file = abs
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot resolve file path: {e}"))?;
|
||||
if !canonical_file.starts_with(&canonical_vault) {
|
||||
return Err("File path is outside the vault".into());
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the file status from `git status --porcelain`.
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain", "--", relative_path])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {e}"))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let line = stdout.lines().find(|l| l.len() >= 4);
|
||||
|
||||
let status_code = line.map(|l| l[..2].trim().to_string()).unwrap_or_default();
|
||||
ensure_path_within_vault(vault, relative_path, &abs)?;
|
||||
let status_code = load_file_status(vault, relative_path)?;
|
||||
|
||||
match status_code.as_str() {
|
||||
"??" => {
|
||||
// Untracked — remove from disk.
|
||||
std::fs::remove_file(&abs)
|
||||
.map_err(|e| format!("Failed to delete untracked file: {e}"))?;
|
||||
}
|
||||
_ => {
|
||||
// Modified, deleted, added-to-index, renamed, etc. — restore via git.
|
||||
// Unstage first (ignore errors — file might not be staged).
|
||||
let _ = Command::new("git")
|
||||
.args(["reset", "HEAD", "--", relative_path])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
let checkout = Command::new("git")
|
||||
.args(["checkout", "--", relative_path])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git checkout: {e}"))?;
|
||||
|
||||
if !checkout.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&checkout.stderr);
|
||||
return Err(format!("git checkout failed: {}", stderr.trim()));
|
||||
}
|
||||
restore_tracked_file(vault, relative_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +270,11 @@ mod tests {
|
||||
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();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files() {
|
||||
let dir = setup_git_repo();
|
||||
@@ -172,6 +304,20 @@ mod tests {
|
||||
let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect();
|
||||
assert!(statuses.contains(&"modified"));
|
||||
assert!(statuses.contains(&"untracked"));
|
||||
|
||||
let modified_entry = modified
|
||||
.iter()
|
||||
.find(|file| file.relative_path == "note.md")
|
||||
.unwrap();
|
||||
assert!(modified_entry.added_lines.is_some());
|
||||
assert!(!modified_entry.binary);
|
||||
|
||||
let untracked_entry = modified
|
||||
.iter()
|
||||
.find(|file| file.relative_path == "new.md")
|
||||
.unwrap();
|
||||
assert_eq!(untracked_entry.added_lines, Some(1));
|
||||
assert_eq!(untracked_entry.deleted_lines, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -201,6 +347,7 @@ mod tests {
|
||||
assert_eq!(modified.len(), 1);
|
||||
assert_eq!(modified[0].status, "untracked");
|
||||
assert_eq!(modified[0].relative_path, "note/brand-new.md");
|
||||
assert_eq!(modified[0].added_lines, Some(1));
|
||||
assert!(
|
||||
modified[0].path.ends_with("/note/brand-new.md"),
|
||||
"Full path should end with relative path: {}",
|
||||
@@ -251,8 +398,7 @@ mod tests {
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Original\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
write_and_commit_markdown(vault, vp, "note.md", "# Original\n");
|
||||
|
||||
// Modify the file
|
||||
fs::write(vault.join("note.md"), "# Changed\n").unwrap();
|
||||
@@ -272,8 +418,7 @@ mod tests {
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("init.md"), "# Init\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
write_and_commit_markdown(vault, vp, "init.md", "# Init\n");
|
||||
|
||||
// Create an untracked file
|
||||
fs::write(vault.join("new.md"), "# New\n").unwrap();
|
||||
@@ -291,8 +436,7 @@ mod tests {
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Original\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
write_and_commit_markdown(vault, vp, "note.md", "# Original\n");
|
||||
|
||||
// Delete the file
|
||||
fs::remove_file(vault.join("note.md")).unwrap();
|
||||
@@ -311,9 +455,7 @@ mod tests {
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
// Need an initial commit so git status works
|
||||
fs::write(vault.join("init.md"), "# Init\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
write_and_commit_markdown(vault, vp, "init.md", "# Init\n");
|
||||
|
||||
let result = discard_file_changes(vp, "../../../etc/passwd");
|
||||
assert!(
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
use super::{GitHubUser, GithubRepo};
|
||||
|
||||
/// Lists the authenticated user's GitHub repositories.
|
||||
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
github_list_repos_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_list_repos_with_base(
|
||||
token: &str,
|
||||
api_base: &str,
|
||||
) -> Result<Vec<GithubRepo>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut all_repos: Vec<GithubRepo> = Vec::new();
|
||||
let mut page = 1u32;
|
||||
|
||||
loop {
|
||||
let url = format!(
|
||||
"{}/user/repos?per_page=100&sort=updated&page={}",
|
||||
api_base, page
|
||||
);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Tolaria-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
let repos: Vec<GithubRepo> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
|
||||
|
||||
let count = repos.len();
|
||||
all_repos.extend(repos);
|
||||
|
||||
if count < 100 {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
if page > 10 {
|
||||
break; // safety limit: 1000 repos max
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_repos)
|
||||
}
|
||||
|
||||
/// Creates a new GitHub repository for the authenticated user.
|
||||
pub async fn github_create_repo(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
github_create_repo_with_base(token, name, private, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_create_repo_with_base(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
api_base: &str,
|
||||
) -> Result<GithubRepo, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"private": private,
|
||||
"auto_init": true,
|
||||
"description": "Tolaria vault"
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/user/repos", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Tolaria-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 422 && body.contains("name already exists") {
|
||||
return Err("Repository name already exists on your account".to_string());
|
||||
}
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<GithubRepo>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))
|
||||
}
|
||||
|
||||
/// Gets the authenticated GitHub user's profile.
|
||||
pub async fn github_get_user(token: &str) -> Result<GitHubUser, String> {
|
||||
github_get_user_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_get_user_with_base(token: &str, api_base: &str) -> Result<GitHubUser, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(format!("{}/user", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Tolaria-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub user request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<GitHubUser>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse user response: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn mock_json(
|
||||
method: &str,
|
||||
path: &str,
|
||||
status: usize,
|
||||
body: &str,
|
||||
) -> (mockito::ServerGuard, mockito::Mock) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock(method, path)
|
||||
.with_status(status)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.create_async()
|
||||
.await;
|
||||
(server, mock)
|
||||
}
|
||||
|
||||
async fn mock_list_repos(status: usize, body: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
let (server, mock) = mock_json(
|
||||
"GET",
|
||||
"/user/repos?per_page=100&sort=updated&page=1",
|
||||
status,
|
||||
body,
|
||||
)
|
||||
.await;
|
||||
let result = github_list_repos_with_base("token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_create_repo(status: usize, body: &str) -> Result<GithubRepo, String> {
|
||||
let (server, mock) = mock_json("POST", "/user/repos", status, body).await;
|
||||
let result = github_create_repo_with_base("token", "repo", false, &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_user(status: usize, body: &str) -> Result<GitHubUser, String> {
|
||||
let (server, mock) = mock_json("GET", "/user", status, body).await;
|
||||
let result = github_get_user_with_base("token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_success() {
|
||||
let repos = mock_list_repos(
|
||||
200,
|
||||
r#"[{"name":"my-repo","full_name":"user/my-repo","description":"A repo","private":false,"clone_url":"https://github.com/user/my-repo.git","html_url":"https://github.com/user/my-repo","updated_at":"2026-02-01T00:00:00Z"}]"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repos.len(), 1);
|
||||
assert_eq!(repos[0].name, "my-repo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_empty() {
|
||||
let repos = mock_list_repos(200, "[]").await.unwrap();
|
||||
assert!(repos.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_auth_error() {
|
||||
let err = mock_list_repos(401, r#"{"message":"Bad credentials"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_paginated() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let repos_page1: Vec<serde_json::Value> = (0..100)
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"name": format!("repo-{}", i),
|
||||
"full_name": format!("user/repo-{}", i),
|
||||
"description": null,
|
||||
"private": false,
|
||||
"clone_url": format!("https://github.com/user/repo-{}.git", i),
|
||||
"html_url": format!("https://github.com/user/repo-{}", i),
|
||||
"updated_at": null
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mock1 = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::to_string(&repos_page1).unwrap())
|
||||
.create_async()
|
||||
.await;
|
||||
let mock2 = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=2")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"[{"name":"extra-repo","full_name":"user/extra-repo","description":null,"private":true,"clone_url":"https://github.com/user/extra-repo.git","html_url":"https://github.com/user/extra-repo","updated_at":null}]"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let repos = github_list_repos_with_base("token", &server.url())
|
||||
.await
|
||||
.unwrap();
|
||||
mock1.assert_async().await;
|
||||
mock2.assert_async().await;
|
||||
|
||||
assert_eq!(repos.len(), 101);
|
||||
assert_eq!(repos[100].name, "extra-repo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_success() {
|
||||
let repo = mock_create_repo(
|
||||
201,
|
||||
r#"{"name":"new-repo","full_name":"user/new-repo","description":"Tolaria vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repo.name, "new-repo");
|
||||
assert!(repo.private);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_name_exists() {
|
||||
let err = mock_create_repo(
|
||||
422,
|
||||
r#"{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("Repository name already exists"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_server_error() {
|
||||
let err = mock_create_repo(500, r#"{"message":"Internal Server Error"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error 500"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_success() {
|
||||
let user = mock_user(
|
||||
200,
|
||||
r#"{"login":"lucaong","name":"Luca Ongaro","avatar_url":"https://avatars.githubusercontent.com/u/12345"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
user,
|
||||
GitHubUser {
|
||||
login: "lucaong".to_string(),
|
||||
name: Some("Luca Ongaro".to_string()),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/12345".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_unauthorized() {
|
||||
let err = mock_user(401, r#"{"message":"Bad credentials"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error 401"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_null_name() {
|
||||
let user = mock_user(
|
||||
200,
|
||||
r#"{"login":"bot-account","name":null,"avatar_url":"https://avatars.githubusercontent.com/u/99"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user.login, "bot-account");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{DeviceFlowPollResult, DeviceFlowStart, GITHUB_CLIENT_ID};
|
||||
|
||||
/// Starts the GitHub OAuth device flow. Returns device code info for user authorization.
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
github_device_flow_start_with_base("https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlowStart, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{}/login/device/code", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "Tolaria-App")
|
||||
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 404 {
|
||||
return Err(
|
||||
"GitHub device flow not available. Ensure a GitHub App is registered with \
|
||||
'Device authorization flow' enabled (Settings → Developer settings → GitHub Apps)."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
return Err(format!("Device flow start failed ({}): {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<DeviceFlowStart>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse device flow response: {}", e))
|
||||
}
|
||||
|
||||
/// Polls GitHub for the device flow authorization result.
|
||||
pub async fn github_device_flow_poll(device_code: &str) -> Result<DeviceFlowPollResult, String> {
|
||||
github_device_flow_poll_with_base(device_code, "https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_poll_with_base(
|
||||
device_code: &str,
|
||||
base_url: &str,
|
||||
) -> Result<DeviceFlowPollResult, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{}/login/oauth/access_token", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "Tolaria-App")
|
||||
.form(&[
|
||||
("client_id", GITHUB_CLIENT_ID),
|
||||
("device_code", device_code),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow poll failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Device flow poll HTTP error: {}", body));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawResponse {
|
||||
access_token: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
let raw: RawResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse poll response: {}", e))?;
|
||||
|
||||
if let Some(token) = raw.access_token {
|
||||
Ok(DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some(token),
|
||||
error: None,
|
||||
})
|
||||
} else {
|
||||
let error = raw.error.unwrap_or_else(|| "unknown".to_string());
|
||||
let status = match error.as_str() {
|
||||
"authorization_pending" | "slow_down" => "pending",
|
||||
"expired_token" => "expired",
|
||||
_ => "error",
|
||||
};
|
||||
Ok(DeviceFlowPollResult {
|
||||
status: status.to_string(),
|
||||
access_token: None,
|
||||
error: Some(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn mock_json(
|
||||
method: &str,
|
||||
path: &str,
|
||||
status: usize,
|
||||
body: &str,
|
||||
) -> (mockito::ServerGuard, mockito::Mock) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock(method, path)
|
||||
.with_status(status)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.create_async()
|
||||
.await;
|
||||
(server, mock)
|
||||
}
|
||||
|
||||
async fn mock_device_start(status: usize, body: &str) -> Result<DeviceFlowStart, String> {
|
||||
let (server, mock) = mock_json("POST", "/login/device/code", status, body).await;
|
||||
let result = github_device_flow_start_with_base(&server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_poll(body: &str) -> DeviceFlowPollResult {
|
||||
let (server, mock) = mock_json("POST", "/login/oauth/access_token", 200, body).await;
|
||||
let result = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
|
||||
.await
|
||||
.unwrap();
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_success() {
|
||||
let start = mock_device_start(
|
||||
200,
|
||||
r#"{"device_code":"dev_abc","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
start,
|
||||
DeviceFlowStart {
|
||||
device_code: "dev_abc".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
verification_uri: "https://github.com/login/device".to_string(),
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_error() {
|
||||
let err = mock_device_start(400, "bad request").await.unwrap_err();
|
||||
assert!(err.contains("Device flow start failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_404_gives_clear_message() {
|
||||
let err = mock_device_start(404, r#"{"error":"Not Found"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("device flow not available"));
|
||||
assert!(err.contains("Device authorization flow"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_complete() {
|
||||
let poll =
|
||||
mock_poll(r#"{"access_token":"gho_secret123","token_type":"bearer","scope":"repo"}"#)
|
||||
.await;
|
||||
assert_eq!(
|
||||
poll,
|
||||
DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some("gho_secret123".to_string()),
|
||||
error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_pending() {
|
||||
let poll = mock_poll(
|
||||
r#"{"error":"authorization_pending","error_description":"The authorization request is still pending."}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("authorization_pending".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_slow_down() {
|
||||
let poll = mock_poll(r#"{"error":"slow_down"}"#).await;
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("slow_down".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_expired() {
|
||||
let poll = mock_poll(r#"{"error":"expired_token"}"#).await;
|
||||
assert_eq!(poll.status, "expired");
|
||||
assert_eq!(poll.error, Some("expired_token".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_other_error() {
|
||||
let poll = mock_poll(r#"{"error":"access_denied"}"#).await;
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("access_denied".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_http_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/oauth/access_token")
|
||||
.with_status(503)
|
||||
.with_body("Service Unavailable")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
|
||||
.await
|
||||
.unwrap_err();
|
||||
mock.assert_async().await;
|
||||
assert!(err.contains("Device flow poll HTTP error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_unknown_error() {
|
||||
let poll = mock_poll(r#"{}"#).await;
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("unknown".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Clones a GitHub repo to a local path using HTTPS + token auth.
|
||||
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
prepare_clone_destination(dest, local_path)?;
|
||||
|
||||
// Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/...
|
||||
let auth_url = inject_token_into_url(url, token)?;
|
||||
|
||||
if let Err(err) = run_clone(&auth_url, local_path) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Configure the remote to use token auth for future pushes
|
||||
if let Err(err) = configure_remote_auth(local_path, url, token) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Ensure sensible .gitignore defaults (especially .DS_Store on macOS)
|
||||
crate::git::ensure_gitignore(local_path)?;
|
||||
|
||||
Ok(format!("Cloned to {}", local_path))
|
||||
}
|
||||
|
||||
/// Clones a public repo to a local path without modifying the remote URL.
|
||||
pub fn clone_public_repo(url: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
prepare_clone_destination(dest, local_path)?;
|
||||
|
||||
if let Err(err) = run_clone(url, local_path) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(format!("Cloned to {}", local_path))
|
||||
}
|
||||
|
||||
/// Injects an OAuth token into an HTTPS GitHub URL.
|
||||
fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
|
||||
if let Some(rest) = url.strip_prefix("https://github.com/") {
|
||||
Ok(format!("https://oauth2:{}@github.com/{}", token, rest))
|
||||
} else if let Some(rest) = url.strip_prefix("https://") {
|
||||
// Handle URLs that already have a host
|
||||
Ok(format!("https://oauth2:{}@{}", token, rest))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Unsupported URL format: {}. Use an HTTPS URL.",
|
||||
url
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_clone_destination(dest: &Path, local_path: &str) -> Result<(), String> {
|
||||
if dest.exists() {
|
||||
if !dest.is_dir() {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not a directory",
|
||||
local_path
|
||||
));
|
||||
}
|
||||
let has_entries = dest
|
||||
.read_dir()
|
||||
.map_err(|e| format!("Failed to inspect destination '{}': {}", local_path, e))?
|
||||
.next()
|
||||
.is_some();
|
||||
if has_entries {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not empty",
|
||||
local_path
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = dest.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create parent directory for '{}': {}",
|
||||
local_path, e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_clone(url: &str, local_path: &str) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
.args(["clone", "--progress", url, local_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("git clone failed: {}", stderr.trim()))
|
||||
}
|
||||
|
||||
fn cleanup_failed_clone(dest: &Path) {
|
||||
if dest.exists() && dest.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up the git remote to use token-based HTTPS auth.
|
||||
fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> {
|
||||
let auth_url = inject_token_into_url(original_url, token)?;
|
||||
let vault = Path::new(local_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "set-url", "origin", &auth_url])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to configure remote: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to set remote URL: {}", stderr));
|
||||
}
|
||||
|
||||
// Also configure git user if not set
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.email", "tolaria@app.local"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.name", "Tolaria App"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
fn clone_err_contains(url: &str, expected: &str) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("dest");
|
||||
let result = clone_repo(url, "token", dest.to_str().unwrap());
|
||||
assert!(result.unwrap_err().contains(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_basic_github_url() {
|
||||
let result = inject_token_into_url("https://github.com/user/repo.git", "gho_abc123");
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"https://oauth2:gho_abc123@github.com/user/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_generic_https_url() {
|
||||
let result = inject_token_into_url("https://gitlab.com/user/repo.git", "glpat-abc");
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"https://oauth2:glpat-abc@gitlab.com/user/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_ssh_url_rejected() {
|
||||
let err = inject_token_into_url("git@github.com:user/repo.git", "token").unwrap_err();
|
||||
assert!(err.contains("Unsupported URL format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_http_url_rejected() {
|
||||
assert!(inject_token_into_url("http://github.com/user/repo.git", "token").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_github_without_dot_git() {
|
||||
let result = inject_token_into_url("https://github.com/user/repo", "tok");
|
||||
assert_eq!(result.unwrap(), "https://oauth2:tok@github.com/user/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_nonempty_dest() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("existing.txt"), "data").unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://github.com/test/repo.git",
|
||||
"token",
|
||||
dir.path().to_str().unwrap(),
|
||||
);
|
||||
assert!(result.unwrap_err().contains("not empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_ssh_url_rejected() {
|
||||
clone_err_contains("git@github.com:user/repo.git", "Unsupported URL format");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_empty_dest_allowed() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("empty-dir");
|
||||
std::fs::create_dir(&dest).unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://github.com/nonexistent/repo.git",
|
||||
"token",
|
||||
dest.to_str().unwrap(),
|
||||
);
|
||||
// Should fail at git clone, not at directory check
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_configure_remote_auth_on_git_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args([
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.com/user/repo.git",
|
||||
])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
configure_remote_auth(
|
||||
path.to_str().unwrap(),
|
||||
"https://github.com/user/repo.git",
|
||||
"gho_test123",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = StdCommand::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
assert_eq!(url, "https://oauth2:gho_test123@github.com/user/repo.git");
|
||||
}
|
||||
|
||||
fn init_local_repo(path: &Path) {
|
||||
std::fs::create_dir_all(path).unwrap();
|
||||
std::fs::write(path.join("welcome.md"), "# Welcome\n").unwrap();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.email", "tolaria@app.local"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.name", "Tolaria App"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["commit", "-m", "Initial vault"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_public_repo_clones_local_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("source");
|
||||
let dest = dir.path().join("dest");
|
||||
init_local_repo(&source);
|
||||
|
||||
let result = clone_public_repo(source.to_str().unwrap(), dest.to_str().unwrap());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
format!("Cloned to {}", dest.to_string_lossy())
|
||||
);
|
||||
assert!(dest.join("welcome.md").exists());
|
||||
|
||||
let status = StdCommand::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&dest)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&status.stdout).trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_public_repo_cleans_failed_clone_destination() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("dest");
|
||||
let missing = dir.path().join("missing-repo");
|
||||
|
||||
let result = clone_public_repo(missing.to_str().unwrap(), dest.to_str().unwrap());
|
||||
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
assert!(!dest.exists());
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
mod api;
|
||||
mod auth;
|
||||
mod clone;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use api::{github_create_repo, github_get_user, github_list_repos};
|
||||
pub use auth::{github_device_flow_poll, github_device_flow_start};
|
||||
pub use clone::{clone_public_repo, clone_repo};
|
||||
|
||||
/// GitHub App client ID for OAuth device flow.
|
||||
/// To set up: GitHub Settings → Developer settings → GitHub Apps → New GitHub App.
|
||||
/// Enable "Device authorization flow" under Optional features. Webhook can be disabled.
|
||||
const GITHUB_CLIENT_ID: &str = "Ov23liwee215tDMs9u4L";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GithubRepo {
|
||||
pub name: String,
|
||||
pub full_name: String,
|
||||
pub description: Option<String>,
|
||||
pub private: bool,
|
||||
pub clone_url: String,
|
||||
pub html_url: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceFlowStart {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceFlowPollResult {
|
||||
pub status: String,
|
||||
pub access_token: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GitHubUser {
|
||||
pub login: String,
|
||||
pub name: Option<String>,
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_serialization_roundtrip() {
|
||||
let repo = GithubRepo {
|
||||
name: "test-repo".to_string(),
|
||||
full_name: "user/test-repo".to_string(),
|
||||
description: Some("A test repo".to_string()),
|
||||
private: true,
|
||||
clone_url: "https://github.com/user/test-repo.git".to_string(),
|
||||
html_url: "https://github.com/user/test-repo".to_string(),
|
||||
updated_at: Some("2026-02-20T10:00:00Z".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&repo).unwrap();
|
||||
assert_eq!(serde_json::from_str::<GithubRepo>(&json).unwrap(), repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_deserialization_null_fields() {
|
||||
let json = r#"{"name":"r","full_name":"u/r","description":null,"private":false,"clone_url":"https://x","html_url":"https://y","updated_at":null}"#;
|
||||
let repo: GithubRepo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(repo.name, "r");
|
||||
assert!(!repo.private);
|
||||
assert!(repo.description.is_none());
|
||||
assert!(repo.updated_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_flow_start_serialization_roundtrip() {
|
||||
let start = DeviceFlowStart {
|
||||
device_code: "dc_123".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
verification_uri: "https://github.com/login/device".to_string(),
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
};
|
||||
let json = serde_json::to_string(&start).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowStart>(&json).unwrap(),
|
||||
start
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_flow_poll_result_roundtrip() {
|
||||
let complete = DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some("gho_abc123".to_string()),
|
||||
error: None,
|
||||
};
|
||||
let json = serde_json::to_string(&complete).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
|
||||
complete
|
||||
);
|
||||
|
||||
let pending = DeviceFlowPollResult {
|
||||
status: "pending".to_string(),
|
||||
access_token: None,
|
||||
error: Some("authorization_pending".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&pending).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
|
||||
pending
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_user_serialization_roundtrip() {
|
||||
let user = GitHubUser {
|
||||
login: "lucaong".to_string(),
|
||||
name: Some("Luca Ongaro".to_string()),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/123".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&user).unwrap();
|
||||
assert_eq!(serde_json::from_str::<GitHubUser>(&json).unwrap(), user);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_user_deserialization_null_name() {
|
||||
let user: GitHubUser =
|
||||
serde_json::from_str(r#"{"login":"bot","name":null,"avatar_url":"https://x"}"#)
|
||||
.unwrap();
|
||||
assert_eq!(user.login, "bot");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
pub mod ai_agents;
|
||||
pub mod app_updater;
|
||||
pub mod claude_cli;
|
||||
mod commands;
|
||||
pub mod frontmatter;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
pub mod mcp;
|
||||
#[cfg(desktop)]
|
||||
pub mod menu;
|
||||
@@ -143,75 +144,84 @@ fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
macro_rules! app_invoke_handler {
|
||||
() => {
|
||||
tauri::generate_handler![
|
||||
commands::list_vault,
|
||||
commands::list_vault_folders,
|
||||
commands::get_note_content,
|
||||
commands::save_note_content,
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
commands::rename_note,
|
||||
commands::rename_note_filename,
|
||||
commands::auto_rename_untitled,
|
||||
commands::detect_renames,
|
||||
commands::update_wikilinks_for_renames,
|
||||
commands::get_file_history,
|
||||
commands::get_modified_files,
|
||||
commands::get_file_diff,
|
||||
commands::get_file_diff_at_commit,
|
||||
commands::get_vault_pulse,
|
||||
commands::git_commit,
|
||||
commands::get_build_number,
|
||||
commands::get_last_commit_info,
|
||||
commands::git_pull,
|
||||
commands::git_push,
|
||||
commands::git_remote_status,
|
||||
commands::get_conflict_files,
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
commands::git_commit_conflict_resolution,
|
||||
commands::git_discard_file,
|
||||
commands::is_git_repo,
|
||||
commands::init_git_repo,
|
||||
commands::check_claude_cli,
|
||||
commands::get_ai_agents_status,
|
||||
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_note_title,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::delete_note,
|
||||
commands::batch_delete_notes,
|
||||
commands::batch_delete_notes_async,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::create_vault_folder,
|
||||
commands::batch_archive_notes,
|
||||
commands::get_settings,
|
||||
commands::check_for_app_update,
|
||||
commands::update_menu_state,
|
||||
commands::trigger_menu_command,
|
||||
commands::update_current_window_min_size,
|
||||
commands::save_settings,
|
||||
commands::download_and_install_app_update,
|
||||
commands::load_vault_list,
|
||||
commands::save_vault_list,
|
||||
commands::clone_repo,
|
||||
commands::search_vault,
|
||||
commands::create_empty_vault,
|
||||
commands::create_getting_started_vault,
|
||||
commands::check_vault_exists,
|
||||
commands::get_default_vault_path,
|
||||
commands::register_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry,
|
||||
commands::list_views,
|
||||
commands::save_view_cmd,
|
||||
commands::delete_view_cmd
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
fn with_invoke_handler(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
builder.invoke_handler(tauri::generate_handler![
|
||||
commands::list_vault,
|
||||
commands::list_vault_folders,
|
||||
commands::get_note_content,
|
||||
commands::save_note_content,
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
commands::rename_note,
|
||||
commands::rename_note_filename,
|
||||
commands::auto_rename_untitled,
|
||||
commands::detect_renames,
|
||||
commands::update_wikilinks_for_renames,
|
||||
commands::get_file_history,
|
||||
commands::get_modified_files,
|
||||
commands::get_file_diff,
|
||||
commands::get_file_diff_at_commit,
|
||||
commands::get_vault_pulse,
|
||||
commands::git_commit,
|
||||
commands::get_build_number,
|
||||
commands::get_last_commit_info,
|
||||
commands::git_pull,
|
||||
commands::git_push,
|
||||
commands::git_remote_status,
|
||||
commands::get_conflict_files,
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
commands::git_commit_conflict_resolution,
|
||||
commands::git_discard_file,
|
||||
commands::is_git_repo,
|
||||
commands::init_git_repo,
|
||||
commands::check_claude_cli,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
commands::reload_vault,
|
||||
commands::reload_vault_entry,
|
||||
commands::sync_note_title,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::delete_note,
|
||||
commands::batch_delete_notes,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::create_vault_folder,
|
||||
commands::batch_archive_notes,
|
||||
commands::get_settings,
|
||||
commands::update_menu_state,
|
||||
commands::trigger_menu_command,
|
||||
commands::save_settings,
|
||||
commands::load_vault_list,
|
||||
commands::save_vault_list,
|
||||
commands::github_list_repos,
|
||||
commands::github_create_repo,
|
||||
commands::clone_repo,
|
||||
commands::github_device_flow_start,
|
||||
commands::github_device_flow_poll,
|
||||
commands::github_get_user,
|
||||
commands::search_vault,
|
||||
commands::create_empty_vault,
|
||||
commands::create_getting_started_vault,
|
||||
commands::check_vault_exists,
|
||||
commands::get_default_vault_path,
|
||||
commands::register_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry,
|
||||
commands::list_views,
|
||||
commands::save_view_cmd,
|
||||
commands::delete_view_cmd
|
||||
])
|
||||
builder.invoke_handler(app_invoke_handler!())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
|
||||
@@ -116,6 +116,32 @@ pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
fn claude_mcp_config_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".claude").join("mcp.json"))
|
||||
}
|
||||
|
||||
fn read_registered_mcp_entry(config_path: &Path) -> Option<serde_json::Value> {
|
||||
let raw = std::fs::read_to_string(config_path).ok()?;
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||
config
|
||||
.get("mcpServers")
|
||||
.and_then(|value| value.as_object())
|
||||
.and_then(|servers| {
|
||||
servers
|
||||
.get(MCP_SERVER_NAME)
|
||||
.or_else(|| servers.get(LEGACY_MCP_SERVER_NAME))
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn entry_index_js_exists(entry: &serde_json::Value) -> bool {
|
||||
entry["args"]
|
||||
.as_array()
|
||||
.and_then(|args| args.first())
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|index_js| Path::new(index_js).exists())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
serde_json::json!({
|
||||
@@ -207,55 +233,33 @@ pub fn check_mcp_status() -> McpStatus {
|
||||
return McpStatus::NoClaudeCli;
|
||||
}
|
||||
|
||||
let config_path = match dirs::home_dir() {
|
||||
Some(h) => h.join(".claude").join("mcp.json"),
|
||||
None => return McpStatus::NotInstalled,
|
||||
let Some(config_path) = claude_mcp_config_path() else {
|
||||
return McpStatus::NotInstalled;
|
||||
};
|
||||
|
||||
if !config_path.exists() {
|
||||
return McpStatus::NotInstalled;
|
||||
}
|
||||
|
||||
let raw = match std::fs::read_to_string(&config_path) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return McpStatus::NotInstalled,
|
||||
};
|
||||
|
||||
let config: serde_json::Value = match serde_json::from_str(&raw) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return McpStatus::NotInstalled,
|
||||
};
|
||||
|
||||
let Some(servers) = config.get("mcpServers").and_then(|value| value.as_object()) else {
|
||||
return McpStatus::NotInstalled;
|
||||
};
|
||||
let Some(entry) = servers
|
||||
.get(MCP_SERVER_NAME)
|
||||
.or_else(|| servers.get(LEGACY_MCP_SERVER_NAME))
|
||||
else {
|
||||
let Some(entry) = read_registered_mcp_entry(&config_path) else {
|
||||
return McpStatus::NotInstalled;
|
||||
};
|
||||
|
||||
// Verify the referenced index.js actually exists on disk
|
||||
if let Some(index_js) = entry["args"]
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if !Path::new(index_js).exists() {
|
||||
return McpStatus::NotInstalled;
|
||||
}
|
||||
if entry_index_js_exists(&entry) {
|
||||
McpStatus::Installed
|
||||
} else {
|
||||
return McpStatus::NotInstalled;
|
||||
McpStatus::NotInstalled
|
||||
}
|
||||
|
||||
McpStatus::Installed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn read_config(config_path: &Path) -> serde_json::Value {
|
||||
let raw = std::fs::read_to_string(config_path).unwrap();
|
||||
serde_json::from_str(&raw).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_mcp_entry_produces_correct_json() {
|
||||
let entry = build_mcp_entry("/path/to/index.js", "/my/vault");
|
||||
@@ -273,8 +277,7 @@ mod tests {
|
||||
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(!was_update);
|
||||
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
let config = read_config(&config_path);
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/test/index.js"
|
||||
@@ -297,8 +300,7 @@ mod tests {
|
||||
let was_update = upsert_mcp_config(&config_path, &entry2).unwrap();
|
||||
assert!(was_update);
|
||||
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
let config = read_config(&config_path);
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
|
||||
"/vault/v2"
|
||||
@@ -325,8 +327,7 @@ mod tests {
|
||||
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(was_update);
|
||||
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
let config = read_config(&config_path);
|
||||
assert!(config["mcpServers"][LEGACY_MCP_SERVER_NAME].is_null());
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
@@ -382,7 +383,7 @@ mod tests {
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
|
||||
// First call
|
||||
register_mcp_to_configs(&entry, &[config.clone()]);
|
||||
register_mcp_to_configs(&entry, std::slice::from_ref(&config));
|
||||
// Second call
|
||||
let status = register_mcp_to_configs(&entry, &[config]);
|
||||
assert_eq!(status, "updated");
|
||||
@@ -458,6 +459,112 @@ mod tests {
|
||||
assert_eq!(status, "registered");
|
||||
}
|
||||
|
||||
#[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 entry = read_registered_mcp_entry(&config_path).unwrap();
|
||||
assert_eq!(entry["env"]["VAULT_PATH"], "/primary");
|
||||
}
|
||||
|
||||
#[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 entry = read_registered_mcp_entry(&config_path).unwrap();
|
||||
assert_eq!(entry["env"]["VAULT_PATH"], "/legacy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_registered_mcp_entry_returns_none_for_invalid_or_missing_servers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let invalid_path = tmp.path().join("invalid.json");
|
||||
std::fs::write(&invalid_path, "{not json").unwrap();
|
||||
assert!(read_registered_mcp_entry(&invalid_path).is_none());
|
||||
|
||||
let empty_path = tmp.path().join("empty.json");
|
||||
let empty_config = serde_json::json!({ "other": {} });
|
||||
std::fs::write(&empty_path, serde_json::to_string(&empty_config).unwrap()).unwrap();
|
||||
assert!(read_registered_mcp_entry(&empty_path).is_none());
|
||||
|
||||
let missing_path = tmp.path().join("missing.json");
|
||||
assert!(read_registered_mcp_entry(&missing_path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_index_js_exists_requires_existing_first_arg() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let index_js = tmp.path().join("index.js");
|
||||
std::fs::write(&index_js, "console.log('ok');").unwrap();
|
||||
|
||||
let existing = serde_json::json!({
|
||||
"args": [index_js.to_string_lossy()]
|
||||
});
|
||||
assert!(entry_index_js_exists(&existing));
|
||||
|
||||
let missing = serde_json::json!({
|
||||
"args": [tmp.path().join("missing.js").to_string_lossy()]
|
||||
});
|
||||
assert!(!entry_index_js_exists(&missing));
|
||||
|
||||
let no_args = serde_json::json!({});
|
||||
assert!(!entry_index_js_exists(&no_args));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_returns_error_for_non_object_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
std::fs::write(&config_path, "[]").unwrap();
|
||||
|
||||
let entry = build_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")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_returns_error_for_non_object_mcp_servers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
let config = serde_json::json!({
|
||||
"mcpServers": []
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
|
||||
|
||||
let entry = build_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"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_mcp_status_returns_valid_variant() {
|
||||
// On a dev machine with Claude CLI and MCP registered, this should be Installed.
|
||||
|
||||
@@ -9,7 +9,6 @@ const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates";
|
||||
|
||||
const FILE_NEW_NOTE: &str = "file-new-note";
|
||||
const FILE_NEW_TYPE: &str = "file-new-type";
|
||||
const FILE_DAILY_NOTE: &str = "file-daily-note";
|
||||
const FILE_QUICK_OPEN: &str = "file-quick-open";
|
||||
const FILE_SAVE: &str = "file-save";
|
||||
|
||||
@@ -57,7 +56,6 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
APP_CHECK_FOR_UPDATES,
|
||||
FILE_NEW_NOTE,
|
||||
FILE_NEW_TYPE,
|
||||
FILE_DAILY_NOTE,
|
||||
FILE_QUICK_OPEN,
|
||||
FILE_SAVE,
|
||||
EDIT_FIND_IN_VAULT,
|
||||
@@ -153,10 +151,6 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
let new_type = MenuItemBuilder::new("New Type")
|
||||
.id(FILE_NEW_TYPE)
|
||||
.build(app)?;
|
||||
let daily_note = MenuItemBuilder::new("Open Today's Note")
|
||||
.id(FILE_DAILY_NOTE)
|
||||
.accelerator("CmdOrCtrl+J")
|
||||
.build(app)?;
|
||||
let quick_open = MenuItemBuilder::new("Quick Open")
|
||||
.id(FILE_QUICK_OPEN)
|
||||
.accelerator("CmdOrCtrl+P")
|
||||
@@ -168,7 +162,6 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
Ok(SubmenuBuilder::new(app, "File")
|
||||
.item(&new_note)
|
||||
.item(&new_type)
|
||||
.item(&daily_note)
|
||||
.item(&quick_open)
|
||||
.separator()
|
||||
.item(&save)
|
||||
@@ -260,11 +253,11 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
let inbox = MenuItemBuilder::new("Inbox").id(GO_INBOX).build(app)?;
|
||||
let go_back = MenuItemBuilder::new("Go Back")
|
||||
.id(VIEW_GO_BACK)
|
||||
.accelerator("CmdOrCtrl+[")
|
||||
.accelerator("CmdOrCtrl+Left")
|
||||
.build(app)?;
|
||||
let go_forward = MenuItemBuilder::new("Go Forward")
|
||||
.id(VIEW_GO_FORWARD)
|
||||
.accelerator("CmdOrCtrl+]")
|
||||
.accelerator("CmdOrCtrl+Right")
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Go")
|
||||
@@ -464,7 +457,6 @@ mod tests {
|
||||
APP_CHECK_FOR_UPDATES,
|
||||
FILE_NEW_NOTE,
|
||||
FILE_NEW_TYPE,
|
||||
FILE_DAILY_NOTE,
|
||||
FILE_QUICK_OPEN,
|
||||
FILE_SAVE,
|
||||
EDIT_FIND_IN_VAULT,
|
||||
|
||||
@@ -7,14 +7,55 @@ const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Settings {
|
||||
pub github_token: Option<String>,
|
||||
pub github_username: Option<String>,
|
||||
pub auto_pull_interval_minutes: Option<u32>,
|
||||
pub telemetry_consent: Option<bool>,
|
||||
pub crash_reporting_enabled: Option<bool>,
|
||||
pub analytics_enabled: Option<bool>,
|
||||
pub anonymous_id: Option<String>,
|
||||
pub release_channel: Option<String>,
|
||||
pub initial_h1_auto_rename_enabled: Option<bool>,
|
||||
pub default_ai_agent: Option<String>,
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|candidate| candidate.trim().to_string())
|
||||
.filter(|candidate| !candidate.is_empty())
|
||||
}
|
||||
|
||||
pub fn normalize_release_channel(value: Option<&str>) -> Option<String> {
|
||||
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
|
||||
Some(channel) if channel == "alpha" => Some(channel),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effective_release_channel(value: Option<&str>) -> &'static str {
|
||||
if normalize_release_channel(value).is_some() {
|
||||
"alpha"
|
||||
} else {
|
||||
"stable"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_default_ai_agent(value: Option<&str>) -> Option<String> {
|
||||
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
|
||||
Some(agent) if agent == "claude_code" || agent == "codex" => Some(agent),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_settings(settings: Settings) -> Settings {
|
||||
Settings {
|
||||
auto_pull_interval_minutes: settings.auto_pull_interval_minutes,
|
||||
telemetry_consent: settings.telemetry_consent,
|
||||
crash_reporting_enabled: settings.crash_reporting_enabled,
|
||||
analytics_enabled: settings.analytics_enabled,
|
||||
anonymous_id: normalize_optional_string(settings.anonymous_id),
|
||||
release_channel: normalize_release_channel(settings.release_channel.as_deref()),
|
||||
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
|
||||
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
|
||||
}
|
||||
}
|
||||
|
||||
fn app_config_dir() -> Result<PathBuf, String> {
|
||||
@@ -51,7 +92,9 @@ fn get_settings_at(path: &PathBuf) -> Result<Settings, String> {
|
||||
}
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read settings: {}", e))?;
|
||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings: {}", e))
|
||||
let settings =
|
||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings: {}", e))?;
|
||||
Ok(normalize_settings(settings))
|
||||
}
|
||||
|
||||
fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> {
|
||||
@@ -60,29 +103,7 @@ fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> {
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
}
|
||||
|
||||
// Trim whitespace and convert empty strings to None
|
||||
let cleaned = Settings {
|
||||
github_token: settings
|
||||
.github_token
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
github_username: settings
|
||||
.github_username
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
auto_pull_interval_minutes: settings.auto_pull_interval_minutes,
|
||||
telemetry_consent: settings.telemetry_consent,
|
||||
crash_reporting_enabled: settings.crash_reporting_enabled,
|
||||
analytics_enabled: settings.analytics_enabled,
|
||||
anonymous_id: settings
|
||||
.anonymous_id
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
release_channel: settings
|
||||
.release_channel
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
};
|
||||
let cleaned = normalize_settings(settings);
|
||||
|
||||
let json = serde_json::to_string_pretty(&cleaned)
|
||||
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
|
||||
@@ -129,6 +150,37 @@ pub fn set_last_vault(vault_path: &str) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
type SettingsSnapshot<'a> = (
|
||||
Option<u32>,
|
||||
Option<bool>,
|
||||
Option<bool>,
|
||||
Option<bool>,
|
||||
Option<&'a str>,
|
||||
Option<&'a str>,
|
||||
Option<bool>,
|
||||
Option<&'a str>,
|
||||
);
|
||||
|
||||
fn settings_snapshot(settings: &Settings) -> SettingsSnapshot<'_> {
|
||||
(
|
||||
settings.auto_pull_interval_minutes,
|
||||
settings.telemetry_consent,
|
||||
settings.crash_reporting_enabled,
|
||||
settings.analytics_enabled,
|
||||
settings.anonymous_id.as_deref(),
|
||||
settings.release_channel.as_deref(),
|
||||
settings.initial_h1_auto_rename_enabled,
|
||||
settings.default_ai_agent.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_empty_settings(settings: &Settings) {
|
||||
assert_eq!(
|
||||
settings_snapshot(settings),
|
||||
(None, None, None, None, None, None, None, None)
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: save settings to a temp file and reload them.
|
||||
fn save_and_reload(settings: Settings) -> Settings {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
@@ -137,37 +189,39 @@ mod tests {
|
||||
get_settings_at(&path).unwrap()
|
||||
}
|
||||
|
||||
fn create_last_vault_path(path_parts: &[&str]) -> (tempfile::TempDir, PathBuf) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = path_parts
|
||||
.iter()
|
||||
.fold(dir.path().to_path_buf(), |acc, part| acc.join(part));
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
fn write_and_assert_last_vault(path: &PathBuf, value: &str) {
|
||||
set_last_vault_at(path, value).unwrap();
|
||||
assert_eq!(get_last_vault_at(path).as_deref(), Some(value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_settings_all_none() {
|
||||
let s = Settings::default();
|
||||
assert!(s.github_token.is_none());
|
||||
assert!(s.github_username.is_none());
|
||||
assert!(s.auto_pull_interval_minutes.is_none());
|
||||
assert!(s.telemetry_consent.is_none());
|
||||
assert!(s.crash_reporting_enabled.is_none());
|
||||
assert!(s.analytics_enabled.is_none());
|
||||
assert!(s.anonymous_id.is_none());
|
||||
assert_empty_settings(&Settings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_json_roundtrip() {
|
||||
let settings = Settings {
|
||||
github_token: Some("gho_xyz789".to_string()),
|
||||
github_username: Some("lucaong".to_string()),
|
||||
auto_pull_interval_minutes: Some(10),
|
||||
telemetry_consent: Some(true),
|
||||
crash_reporting_enabled: Some(true),
|
||||
analytics_enabled: Some(false),
|
||||
anonymous_id: Some("abc-123-uuid".to_string()),
|
||||
..Default::default()
|
||||
release_channel: Some("alpha".to_string()),
|
||||
initial_h1_auto_rename_enabled: Some(false),
|
||||
default_ai_agent: Some("codex".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
let parsed: Settings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.github_token, settings.github_token);
|
||||
assert_eq!(parsed.github_username, settings.github_username);
|
||||
assert_eq!(parsed.telemetry_consent, Some(true));
|
||||
assert_eq!(parsed.crash_reporting_enabled, Some(true));
|
||||
assert_eq!(parsed.analytics_enabled, Some(false));
|
||||
assert_eq!(parsed.anonymous_id.as_deref(), Some("abc-123-uuid"));
|
||||
assert_eq!(settings_snapshot(&parsed), settings_snapshot(&settings));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -175,40 +229,72 @@ mod tests {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nonexistent.json");
|
||||
let result = get_settings_at(&path).unwrap();
|
||||
assert!(result.github_token.is_none());
|
||||
assert!(result.auto_pull_interval_minutes.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_preserves_values() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
github_token: Some("gho_token123".to_string()),
|
||||
github_username: Some("lucaong".to_string()),
|
||||
auto_pull_interval_minutes: Some(10),
|
||||
release_channel: Some("alpha".to_string()),
|
||||
initial_h1_auto_rename_enabled: Some(false),
|
||||
default_ai_agent: Some("codex".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(loaded.github_token.as_deref(), Some("gho_token123"));
|
||||
assert_eq!(loaded.github_username.as_deref(), Some("lucaong"));
|
||||
assert_eq!(loaded.auto_pull_interval_minutes, Some(10));
|
||||
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
|
||||
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
|
||||
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_trims_whitespace() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
github_token: Some(" gho_abc ".to_string()),
|
||||
github_username: Some(" lucaong ".to_string()),
|
||||
anonymous_id: Some(" test-uuid ".to_string()),
|
||||
release_channel: Some(" alpha ".to_string()),
|
||||
default_ai_agent: Some(" codex ".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(loaded.github_token.as_deref(), Some("gho_abc"));
|
||||
assert_eq!(loaded.github_username.as_deref(), Some("lucaong"));
|
||||
assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid"));
|
||||
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
|
||||
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_filters_empty_and_whitespace_only() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
github_username: Some("".to_string()),
|
||||
release_channel: Some("".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(loaded.github_username.is_none());
|
||||
assert!(loaded.release_channel.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_alpha_release_channels_normalize_to_stable() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
release_channel: Some("beta".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(loaded.release_channel.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_default_ai_agent_is_filtered() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
default_ai_agent: Some("cursor".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(loaded.default_ai_agent.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_settings_normalizes_legacy_beta_channel() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("settings.json");
|
||||
fs::write(&path, r#"{"release_channel":"beta"}"#).unwrap();
|
||||
|
||||
let loaded = get_settings_at(&path).unwrap();
|
||||
assert!(loaded.release_channel.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -219,15 +305,15 @@ mod tests {
|
||||
save_settings_at(
|
||||
&path,
|
||||
Settings {
|
||||
github_token: Some("gho_test".to_string()),
|
||||
anonymous_id: Some("test-uuid".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(
|
||||
get_settings_at(&path).unwrap().github_token.as_deref(),
|
||||
Some("gho_test")
|
||||
get_settings_at(&path).unwrap().anonymous_id.as_deref(),
|
||||
Some("test-uuid")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -250,24 +336,33 @@ mod tests {
|
||||
anonymous_id: Some("test-uuid-v4".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(loaded.telemetry_consent, Some(true));
|
||||
assert_eq!(loaded.crash_reporting_enabled, Some(true));
|
||||
assert_eq!(loaded.analytics_enabled, Some(false));
|
||||
assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid-v4"));
|
||||
assert_eq!(
|
||||
settings_snapshot(&loaded),
|
||||
(
|
||||
None,
|
||||
Some(true),
|
||||
Some(true),
|
||||
Some(false),
|
||||
Some("test-uuid-v4"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_old_settings_json_missing_telemetry_fields() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("settings.json");
|
||||
// Simulate old settings.json without telemetry fields
|
||||
fs::write(&path, r#"{"github_token":"gho_test"}"#).unwrap();
|
||||
// Simulate an old settings.json that still contains removed GitHub auth fields.
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"github_token":"gho_test","github_username":"lucaong"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let loaded = get_settings_at(&path).unwrap();
|
||||
assert_eq!(loaded.github_token.as_deref(), Some("gho_test"));
|
||||
assert!(loaded.telemetry_consent.is_none());
|
||||
assert!(loaded.crash_reporting_enabled.is_none());
|
||||
assert!(loaded.analytics_enabled.is_none());
|
||||
assert!(loaded.anonymous_id.is_none());
|
||||
assert_empty_settings(&loaded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -299,24 +394,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_set_and_get_last_vault_roundtrip() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/MyVault").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/MyVault")
|
||||
);
|
||||
let (_dir, path) = create_last_vault_path(&["last-vault.txt"]);
|
||||
write_and_assert_last_vault(&path, "/Users/test/MyVault");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_trims_whitespace() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, " /Users/test/Vault ").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/Vault")
|
||||
);
|
||||
let (_dir, path) = create_last_vault_path(&["last-vault.txt"]);
|
||||
write_and_assert_last_vault(&path, "/Users/test/Vault");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -329,25 +414,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_creates_parent_directories() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nested").join("dir").join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/Vault").unwrap();
|
||||
let (_dir, path) = create_last_vault_path(&["nested", "dir", "last-vault.txt"]);
|
||||
write_and_assert_last_vault(&path, "/Users/test/Vault");
|
||||
assert!(path.exists());
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/Vault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_overwrites_previous() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/OldVault").unwrap();
|
||||
set_last_vault_at(&path, "/Users/test/NewVault").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/NewVault")
|
||||
);
|
||||
let (_dir, path) = create_last_vault_path(&["last-vault.txt"]);
|
||||
write_and_assert_last_vault(&path, "/Users/test/OldVault");
|
||||
write_and_assert_last_vault(&path, "/Users/test/NewVault");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ fn prune_stale_entries(vault: &Path, entries: &mut Vec<VaultEntry>) -> bool {
|
||||
/// Sort entries by modified_at descending and write the cache.
|
||||
fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String) -> Vec<VaultEntry> {
|
||||
prune_stale_entries(vault, &mut entries);
|
||||
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
entries.sort_by_key(|entry| std::cmp::Reverse(entry.modified_at));
|
||||
write_cache(
|
||||
vault,
|
||||
&VaultCache {
|
||||
@@ -327,6 +327,21 @@ fn update_different_commit(
|
||||
finalize_and_cache(vault, entries, current_hash)
|
||||
}
|
||||
|
||||
fn cache_requires_full_rescan(cache: &VaultCache, vault_path: &Path) -> bool {
|
||||
let current_vault_str = vault_path.to_string_lossy();
|
||||
cache.version != CACHE_VERSION
|
||||
|| (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref())
|
||||
}
|
||||
|
||||
fn scan_and_cache_full(
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
current_hash: String,
|
||||
) -> Result<Vec<VaultEntry>, String> {
|
||||
let entries = scan_vault(vault_path, git_dates)?;
|
||||
Ok(finalize_and_cache(vault_path, entries, current_hash))
|
||||
}
|
||||
|
||||
/// Delete the cache file for a vault, forcing a full rescan on the next
|
||||
/// call to `scan_vault_cached`. Used by the `reload_vault` command so that
|
||||
/// explicit user-triggered reloads always read from the filesystem.
|
||||
@@ -357,12 +372,8 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
let git_dates = get_all_file_dates(vault_path);
|
||||
|
||||
if let Some(cache) = load_cache(vault_path) {
|
||||
let current_vault_str = vault_path.to_string_lossy();
|
||||
let cache_stale = cache.version != CACHE_VERSION
|
||||
|| (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref());
|
||||
if cache_stale {
|
||||
let entries = scan_vault(vault_path, &git_dates)?;
|
||||
return Ok(finalize_and_cache(vault_path, entries, current_hash));
|
||||
if cache_requires_full_rescan(&cache, vault_path) {
|
||||
return scan_and_cache_full(vault_path, &git_dates, current_hash);
|
||||
}
|
||||
return if cache.commit_hash == current_hash {
|
||||
Ok(update_same_commit(vault_path, cache, &git_dates))
|
||||
@@ -377,8 +388,7 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
}
|
||||
|
||||
// No cache — full scan and write cache
|
||||
let entries = scan_vault(vault_path, &git_dates)?;
|
||||
Ok(finalize_and_cache(vault_path, entries, current_hash))
|
||||
scan_and_cache_full(vault_path, &git_dates, current_hash)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -607,7 +617,7 @@ mod tests {
|
||||
assert!(
|
||||
entries[0]
|
||||
.path
|
||||
.starts_with(&vault.to_string_lossy().as_ref()),
|
||||
.starts_with(vault.to_string_lossy().as_ref()),
|
||||
"Entry path should start with vault path"
|
||||
);
|
||||
|
||||
@@ -615,7 +625,7 @@ mod tests {
|
||||
let cache_file = cache_path(vault);
|
||||
let cache_data = fs::read_to_string(&cache_file).unwrap();
|
||||
let tampered = cache_data.replace(
|
||||
&vault.to_string_lossy().as_ref(),
|
||||
vault.to_string_lossy().as_ref(),
|
||||
"/Users/other-machine/OtherVault",
|
||||
);
|
||||
fs::write(&cache_file, tampered).unwrap();
|
||||
@@ -626,7 +636,7 @@ mod tests {
|
||||
assert!(
|
||||
entries2[0]
|
||||
.path
|
||||
.starts_with(&vault.to_string_lossy().as_ref()),
|
||||
.starts_with(vault.to_string_lossy().as_ref()),
|
||||
"After stale-cache invalidation, paths should use the current vault path, got: {}",
|
||||
entries2[0].path
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::getting_started::AGENTS_MD;
|
||||
use super::getting_started::{agents_content_can_be_refreshed, AGENTS_MD};
|
||||
|
||||
/// Content for `config.md` — gives the Config type a sidebar icon and label.
|
||||
const CONFIG_TYPE_DEFINITION: &str = "\
|
||||
@@ -18,6 +19,33 @@ sidebar label: Config
|
||||
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
|
||||
";
|
||||
|
||||
const CLAUDE_MD_SHIM: &str = "@AGENTS.md
|
||||
|
||||
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiGuidanceFileState {
|
||||
Managed,
|
||||
Missing,
|
||||
Broken,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct VaultAiGuidanceStatus {
|
||||
pub agents_state: AiGuidanceFileState,
|
||||
pub claude_state: AiGuidanceFileState,
|
||||
pub can_restore: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct LegacyAgentsMigrationOutcome {
|
||||
copied_to_root: bool,
|
||||
removed_legacy: bool,
|
||||
}
|
||||
|
||||
/// Write a file if it doesn't exist or is empty (corrupt). Returns true if written.
|
||||
fn write_if_missing(path: &Path, content: &str) -> Result<bool, String> {
|
||||
let needs_write = !path.exists() || fs::metadata(path).map_or(true, |m| m.len() == 0);
|
||||
@@ -27,17 +55,173 @@ fn write_if_missing(path: &Path, content: &str) -> Result<bool, String> {
|
||||
Ok(needs_write)
|
||||
}
|
||||
|
||||
fn read_file_or_empty(path: &Path) -> String {
|
||||
fs::read_to_string(path).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn agents_content_can_be_replaced(content: &str) -> bool {
|
||||
content.contains("See config/agents.md") || agents_content_can_be_refreshed(content)
|
||||
}
|
||||
|
||||
fn root_agents_can_be_replaced(path: &Path) -> bool {
|
||||
!path.exists() || agents_content_can_be_replaced(&read_file_or_empty(path))
|
||||
}
|
||||
|
||||
fn matches_claude_shim(content: &str) -> bool {
|
||||
let trimmed = content.trim();
|
||||
trimmed == "@AGENTS.md" || trimmed == CLAUDE_MD_SHIM.trim()
|
||||
}
|
||||
|
||||
fn claude_shim_can_be_replaced(path: &Path) -> bool {
|
||||
!path.exists() || {
|
||||
let content = read_file_or_empty(path);
|
||||
content.trim().is_empty() || matches_claude_shim(&content)
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_managed_file(
|
||||
path: &Path,
|
||||
content: &str,
|
||||
can_replace: fn(&Path) -> bool,
|
||||
) -> Result<bool, String> {
|
||||
if !can_replace(path) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn classify_guidance_file(
|
||||
path: &Path,
|
||||
matches_managed: fn(&str) -> bool,
|
||||
can_replace: fn(&Path) -> bool,
|
||||
) -> AiGuidanceFileState {
|
||||
if !path.exists() {
|
||||
return AiGuidanceFileState::Missing;
|
||||
}
|
||||
|
||||
let content = read_file_or_empty(path);
|
||||
if matches_managed(&content) {
|
||||
return AiGuidanceFileState::Managed;
|
||||
}
|
||||
|
||||
if can_replace(path) {
|
||||
return AiGuidanceFileState::Broken;
|
||||
}
|
||||
|
||||
AiGuidanceFileState::Custom
|
||||
}
|
||||
|
||||
fn guidance_paths(vault_path: &str) -> (PathBuf, PathBuf) {
|
||||
let vault = Path::new(vault_path);
|
||||
(vault.join("AGENTS.md"), vault.join("CLAUDE.md"))
|
||||
}
|
||||
|
||||
fn classify_agents_file(path: &Path) -> AiGuidanceFileState {
|
||||
classify_guidance_file(
|
||||
path,
|
||||
|content| content == AGENTS_MD,
|
||||
root_agents_can_be_replaced,
|
||||
)
|
||||
}
|
||||
|
||||
fn classify_claude_file(path: &Path) -> AiGuidanceFileState {
|
||||
classify_guidance_file(path, matches_claude_shim, claude_shim_can_be_replaced)
|
||||
}
|
||||
|
||||
fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
|
||||
matches!(
|
||||
state,
|
||||
AiGuidanceFileState::Missing | AiGuidanceFileState::Broken
|
||||
)
|
||||
}
|
||||
|
||||
fn build_ai_guidance_status(vault_path: &str) -> VaultAiGuidanceStatus {
|
||||
let (agents_path, claude_path) = guidance_paths(vault_path);
|
||||
let agents_state = classify_agents_file(&agents_path);
|
||||
let claude_state = classify_claude_file(&claude_path);
|
||||
|
||||
VaultAiGuidanceStatus {
|
||||
agents_state,
|
||||
claude_state,
|
||||
can_restore: guidance_file_needs_restore(agents_state)
|
||||
|| guidance_file_needs_restore(claude_state),
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_claude_shim_file(vault_path: &str) -> Result<bool, String> {
|
||||
let (_, claude_path) = guidance_paths(vault_path);
|
||||
sync_managed_file(&claude_path, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
|
||||
}
|
||||
|
||||
fn sync_ai_guidance_files(vault_path: &str) -> Result<bool, String> {
|
||||
let wrote_agents = sync_default_agents_file(vault_path)?;
|
||||
let wrote_claude = sync_claude_shim_file(vault_path)?;
|
||||
Ok(wrote_agents || wrote_claude)
|
||||
}
|
||||
|
||||
fn migrate_legacy_agents_file(
|
||||
root_agents: &Path,
|
||||
config_agents: &Path,
|
||||
) -> Result<LegacyAgentsMigrationOutcome, String> {
|
||||
let mut outcome = LegacyAgentsMigrationOutcome::default();
|
||||
if !config_agents.exists() {
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
let config_content = read_file_or_empty(config_agents);
|
||||
if !config_content.is_empty() && root_agents_can_be_replaced(root_agents) {
|
||||
fs::write(root_agents, &config_content)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md: {e}"))?;
|
||||
outcome.copied_to_root = true;
|
||||
}
|
||||
|
||||
fs::remove_file(config_agents)
|
||||
.map_err(|e| format!("Failed to remove config/agents.md: {e}"))?;
|
||||
outcome.removed_legacy = true;
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
fn cleanup_empty_config_dir(vault: &Path) -> Result<bool, String> {
|
||||
let config_dir = vault.join("config");
|
||||
if !config_dir.is_dir() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let is_empty = fs::read_dir(&config_dir)
|
||||
.map_err(|e| format!("Failed to inspect {}: {e}", config_dir.display()))?
|
||||
.next()
|
||||
.is_none();
|
||||
if !is_empty {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
fs::remove_dir(&config_dir)
|
||||
.map_err(|e| format!("Failed to remove {}: {e}", config_dir.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn sync_default_agents_file(vault_path: &str) -> Result<bool, String> {
|
||||
let (agents_path, _) = guidance_paths(vault_path);
|
||||
sync_managed_file(&agents_path, AGENTS_MD, root_agents_can_be_replaced)
|
||||
}
|
||||
|
||||
pub fn get_ai_guidance_status(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
|
||||
Ok(build_ai_guidance_status(vault_path))
|
||||
}
|
||||
|
||||
pub fn restore_ai_guidance_files(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
|
||||
sync_ai_guidance_files(vault_path)?;
|
||||
get_ai_guidance_status(vault_path)
|
||||
}
|
||||
|
||||
/// Seed `AGENTS.md` at vault root if missing or empty (idempotent, per-file).
|
||||
/// Also seeds `config.md` type definition for sidebar visibility.
|
||||
pub fn seed_config_files(vault_path: &str) {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let agents_path = vault.join("AGENTS.md");
|
||||
let needs_write =
|
||||
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
let _ = fs::write(&agents_path, AGENTS_MD);
|
||||
log::info!("Seeded AGENTS.md at vault root");
|
||||
if sync_ai_guidance_files(vault_path).unwrap_or(false) {
|
||||
log::info!("Seeded vault AI guidance files at vault root");
|
||||
}
|
||||
|
||||
ensure_config_type_definition(vault_path);
|
||||
@@ -46,10 +230,7 @@ pub fn seed_config_files(vault_path: &str) {
|
||||
/// Ensure `config.md` exists at vault root (gives Config type a sidebar icon/color).
|
||||
fn ensure_config_type_definition(vault_path: &str) {
|
||||
let path = Path::new(vault_path).join("config.md");
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
let _ = fs::write(&path, CONFIG_TYPE_DEFINITION);
|
||||
}
|
||||
let _ = write_if_missing(&path, CONFIG_TYPE_DEFINITION);
|
||||
}
|
||||
|
||||
/// Migrate legacy `config/agents.md` → root `AGENTS.md` for existing vaults.
|
||||
@@ -65,39 +246,20 @@ pub fn migrate_agents_md(vault_path: &str) {
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
let config_agents = vault.join("config").join("agents.md");
|
||||
|
||||
// If legacy config/agents.md exists with real content, migrate it to root
|
||||
if config_agents.exists() {
|
||||
let config_content = fs::read_to_string(&config_agents).unwrap_or_default();
|
||||
if !config_content.is_empty() {
|
||||
// Only migrate if root AGENTS.md is missing, empty, or is a stub
|
||||
let root_is_stub_or_missing = !root_agents.exists()
|
||||
|| fs::read_to_string(&root_agents)
|
||||
.map_or(true, |c| c.is_empty() || c.contains("See config/agents.md"));
|
||||
|
||||
if root_is_stub_or_missing {
|
||||
let _ = fs::write(&root_agents, &config_content);
|
||||
log::info!("Migrated config/agents.md content to root AGENTS.md");
|
||||
}
|
||||
// Remove legacy file
|
||||
let _ = fs::remove_file(&config_agents);
|
||||
if let Ok(outcome) = migrate_legacy_agents_file(&root_agents, &config_agents) {
|
||||
if outcome.copied_to_root {
|
||||
log::info!("Migrated config/agents.md content to root AGENTS.md");
|
||||
}
|
||||
if outcome.removed_legacy {
|
||||
log::info!("Removed legacy config/agents.md");
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty config/ directory
|
||||
let config_dir = vault.join("config");
|
||||
if config_dir.is_dir() {
|
||||
let is_empty = fs::read_dir(&config_dir).map_or(true, |mut d| d.next().is_none());
|
||||
if is_empty {
|
||||
let _ = fs::remove_dir(&config_dir);
|
||||
log::info!("Removed empty config/ directory");
|
||||
}
|
||||
if cleanup_empty_config_dir(vault).unwrap_or(false) {
|
||||
log::info!("Removed empty config/ directory");
|
||||
}
|
||||
|
||||
// Ensure root AGENTS.md exists with content
|
||||
if !root_agents.exists() {
|
||||
let _ = fs::write(&root_agents, AGENTS_MD);
|
||||
}
|
||||
let _ = sync_ai_guidance_files(vault_path);
|
||||
}
|
||||
|
||||
/// Repair config files: ensure `AGENTS.md` at vault root and `config.md` type definition.
|
||||
@@ -108,36 +270,10 @@ pub fn repair_config_files(vault_path: &str) -> Result<String, String> {
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
let config_agents = vault.join("config").join("agents.md");
|
||||
|
||||
// Step 1: Migrate legacy config/agents.md → root AGENTS.md
|
||||
if config_agents.exists() {
|
||||
let config_content = fs::read_to_string(&config_agents).unwrap_or_default();
|
||||
if !config_content.is_empty() {
|
||||
let root_is_stub_or_missing = !root_agents.exists()
|
||||
|| fs::read_to_string(&root_agents)
|
||||
.map_or(true, |c| c.is_empty() || c.contains("See config/agents.md"));
|
||||
migrate_legacy_agents_file(&root_agents, &config_agents)?;
|
||||
let _ = cleanup_empty_config_dir(vault)?;
|
||||
sync_ai_guidance_files(vault_path)?;
|
||||
|
||||
if root_is_stub_or_missing {
|
||||
fs::write(&root_agents, &config_content)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md: {e}"))?;
|
||||
}
|
||||
fs::remove_file(&config_agents)
|
||||
.map_err(|e| format!("Failed to remove config/agents.md: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Clean up empty config/ directory
|
||||
let config_dir = vault.join("config");
|
||||
if config_dir.is_dir() {
|
||||
let is_empty = fs::read_dir(&config_dir).map_or(true, |mut d| d.next().is_none());
|
||||
if is_empty {
|
||||
let _ = fs::remove_dir(&config_dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Seed AGENTS.md with defaults if still missing or empty
|
||||
write_if_missing(&root_agents, AGENTS_MD)?;
|
||||
|
||||
// Step 4: Ensure config.md type definition at vault root
|
||||
write_if_missing(&vault.join("config.md"), CONFIG_TYPE_DEFINITION)?;
|
||||
|
||||
Ok("Config files repaired".to_string())
|
||||
@@ -149,26 +285,140 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_agents_at_root() {
|
||||
fn create_vault() -> (TempDir, PathBuf) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
(dir, vault)
|
||||
}
|
||||
|
||||
fn config_dir(vault: &Path) -> PathBuf {
|
||||
let dir = vault.join("config");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_root_agents(vault: &Path, content: &str) {
|
||||
fs::write(vault.join("AGENTS.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn write_root_claude(vault: &Path, content: &str) {
|
||||
fs::write(vault.join("CLAUDE.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn write_legacy_agents(vault: &Path, content: &str) {
|
||||
fs::write(config_dir(vault).join("agents.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn read_root_agents(vault: &Path) -> String {
|
||||
fs::read_to_string(vault.join("AGENTS.md")).unwrap()
|
||||
}
|
||||
|
||||
fn read_root_claude(vault: &Path) -> String {
|
||||
fs::read_to_string(vault.join("CLAUDE.md")).unwrap()
|
||||
}
|
||||
|
||||
type VaultOperation = fn(&Path);
|
||||
|
||||
fn run_seed(vault: &Path) {
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
}
|
||||
|
||||
fn run_migrate(vault: &Path) {
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
}
|
||||
|
||||
fn run_repair(vault: &Path) {
|
||||
repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn run_with_agents(
|
||||
run: VaultOperation,
|
||||
root_agents: Option<&str>,
|
||||
legacy_agents: Option<&str>,
|
||||
) -> (TempDir, PathBuf) {
|
||||
let (dir, vault) = create_vault();
|
||||
if let Some(content) = root_agents {
|
||||
write_root_agents(&vault, content);
|
||||
}
|
||||
if let Some(content) = legacy_agents {
|
||||
write_legacy_agents(&vault, content);
|
||||
}
|
||||
|
||||
run(&vault);
|
||||
(dir, vault)
|
||||
}
|
||||
|
||||
fn assert_preserves_custom_agents(run: VaultOperation) {
|
||||
let (_dir, vault) = run_with_agents(
|
||||
run,
|
||||
Some("# Custom Agent Config\nMy custom instructions\n"),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
read_root_agents(&vault).contains("Custom Agent Config"),
|
||||
"must preserve existing content"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_refreshes_outdated_managed_agents(run: VaultOperation) {
|
||||
let outdated_agents = AGENTS_MD.replacen(
|
||||
"Store note type in the `type:` frontmatter field.",
|
||||
"`type:` is the preferred type field. Tolaria still understands legacy aliases such as `Is A`.",
|
||||
1,
|
||||
);
|
||||
let (_dir, vault) = run_with_agents(run, Some(&outdated_agents), None);
|
||||
|
||||
let content = read_root_agents(&vault);
|
||||
assert!(content.contains("Store note type in the `type:` frontmatter field."));
|
||||
assert!(!content.contains("Tolaria still understands legacy aliases such as `Is A`."));
|
||||
}
|
||||
|
||||
fn assert_legacy_agents_move_to_root(
|
||||
run: VaultOperation,
|
||||
legacy_agents: &str,
|
||||
expected_root_text: &str,
|
||||
expect_config_dir_removed: bool,
|
||||
) {
|
||||
let (_dir, vault) = run_with_agents(run, None, Some(legacy_agents));
|
||||
|
||||
let config_dir = vault.join("config");
|
||||
let root_content = read_root_agents(&vault);
|
||||
assert!(root_content.contains(expected_root_text));
|
||||
assert!(!config_dir.join("agents.md").exists());
|
||||
assert_eq!(config_dir.exists(), !expect_config_dir_removed);
|
||||
}
|
||||
|
||||
fn assert_stub_agents_are_replaced(
|
||||
run: VaultOperation,
|
||||
legacy_agents: &str,
|
||||
expected_root_text: &str,
|
||||
) {
|
||||
let (_dir, vault) = run_with_agents(
|
||||
run,
|
||||
Some("# Agent Instructions\nSee config/agents.md for vault instructions.\n"),
|
||||
Some(legacy_agents),
|
||||
);
|
||||
|
||||
let content = read_root_agents(&vault);
|
||||
assert!(content.contains(expected_root_text));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_guidance_files_at_root() {
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(content.contains("Tolaria Vault"));
|
||||
// Must NOT create config/ directory
|
||||
assert!(!vault.join("config").exists());
|
||||
assert!(read_root_agents(&vault).contains("Tolaria Vault"));
|
||||
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_type_definition() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
@@ -176,111 +426,103 @@ mod tests {
|
||||
let content = fs::read_to_string(vault.join("config.md")).unwrap();
|
||||
assert!(content.contains("type: Type"));
|
||||
assert!(content.contains("icon: gear-six"));
|
||||
assert!(!vault.join("config").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let custom = "# Custom Agent Config\nMy custom instructions\n";
|
||||
fs::write(vault.join("AGENTS.md"), custom).unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("Custom Agent Config"),
|
||||
"must preserve existing content"
|
||||
);
|
||||
fn test_seed_config_files_preserves_custom_agents() {
|
||||
assert_preserves_custom_agents(run_seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_reseeds_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("AGENTS.md"), "").unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "");
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(content.contains("Tolaria Vault"));
|
||||
assert!(read_root_agents(&vault).contains("Tolaria Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_refreshes_stale_default_agents() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(
|
||||
&vault,
|
||||
"# AGENTS.md — Tolaria Vault\n\n- The first H1 in the body is the note title. Do not add `title:` frontmatter.\n",
|
||||
);
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
let content = read_root_agents(&vault);
|
||||
assert!(content.contains("Legacy `title:` frontmatter is still read as a fallback"));
|
||||
assert!(content.contains("views/*.yml"));
|
||||
assert!(content.contains("Belongs to:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_refreshes_outdated_managed_agents() {
|
||||
assert_refreshes_outdated_managed_agents(run_seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_preserves_custom_claude() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_claude(&vault, "# Custom Claude instructions\nDo not overwrite\n");
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(read_root_claude(&vault).contains("Custom Claude instructions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_moves_config_to_root() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
let custom = "# My vault agent instructions\nCustom content\n";
|
||||
fs::write(config_dir.join("agents.md"), custom).unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
let root_content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root_content.contains("My vault agent instructions"));
|
||||
assert!(!config_dir.join("agents.md").exists());
|
||||
assert!(!config_dir.exists());
|
||||
assert_legacy_agents_move_to_root(
|
||||
run_migrate,
|
||||
"# My vault agent instructions\nCustom content\n",
|
||||
"My vault agent instructions",
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_preserves_existing_root() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
let custom_root = "# My root agent config\nDo not overwrite\n";
|
||||
fs::write(vault.join("AGENTS.md"), custom_root).unwrap();
|
||||
fs::write(config_dir.join("agents.md"), "Legacy content").unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "# My root agent config\nDo not overwrite\n");
|
||||
write_legacy_agents(&vault, "Legacy content");
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
let config_dir = vault.join("config");
|
||||
let content = read_root_agents(&vault);
|
||||
assert!(content.contains("My root agent config"));
|
||||
assert!(!config_dir.join("agents.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_replaces_stub_with_config_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
fs::write(
|
||||
vault.join("AGENTS.md"),
|
||||
"# Agent Instructions\nSee config/agents.md for vault instructions.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let real_content = "# Real Agent Config\nImportant instructions\n";
|
||||
fs::write(config_dir.join("agents.md"), real_content).unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(content.contains("Real Agent Config"));
|
||||
assert_stub_agents_are_replaced(
|
||||
run_migrate,
|
||||
"# Real Agent Config\nImportant instructions\n",
|
||||
"Real Agent Config",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_idempotent_when_no_legacy() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
let root = read_root_agents(&vault);
|
||||
assert!(root.contains("Tolaria Vault"));
|
||||
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_keeps_nonempty_config_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
let config_dir = config_dir(&vault);
|
||||
fs::write(config_dir.join("agents.md"), "Agent content").unwrap();
|
||||
fs::write(config_dir.join("other.md"), "Other file").unwrap();
|
||||
|
||||
@@ -293,71 +535,83 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_creates_all() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
let msg = repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(msg, "Config files repaired");
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
assert!(vault.join("CLAUDE.md").exists());
|
||||
assert!(vault.join("config.md").exists());
|
||||
assert!(!vault.join("config").exists());
|
||||
|
||||
let agents = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
let agents = read_root_agents(&vault);
|
||||
assert!(agents.contains("Tolaria Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_preserves_custom_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let custom = "# My custom agent config\nDo not overwrite me\n";
|
||||
fs::write(vault.join("AGENTS.md"), custom).unwrap();
|
||||
|
||||
repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("My custom agent config"),
|
||||
"must preserve existing content"
|
||||
);
|
||||
assert_preserves_custom_agents(run_repair);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_migrates_legacy_config() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
let original = "# My vault agents instructions\nCustom content here\n";
|
||||
fs::write(config_dir.join("agents.md"), original).unwrap();
|
||||
|
||||
repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("My vault agents instructions"));
|
||||
assert!(!config_dir.join("agents.md").exists());
|
||||
assert_legacy_agents_move_to_root(
|
||||
run_repair,
|
||||
"# My vault agent instructions\nCustom content\n",
|
||||
"My vault agent instructions",
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_replaces_stub_with_legacy() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
fs::write(
|
||||
vault.join("AGENTS.md"),
|
||||
"# Agent Instructions\nSee config/agents.md for vault instructions.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let real = "# Real Instructions\nImportant stuff\n";
|
||||
fs::write(config_dir.join("agents.md"), real).unwrap();
|
||||
assert_stub_agents_are_replaced(
|
||||
run_repair,
|
||||
"# Real Instructions\nImportant stuff\n",
|
||||
"Real Instructions",
|
||||
);
|
||||
}
|
||||
|
||||
repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
#[test]
|
||||
fn test_repair_config_files_refreshes_outdated_managed_agents() {
|
||||
assert_refreshes_outdated_managed_agents(run_repair);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(content.contains("Real Instructions"));
|
||||
#[test]
|
||||
fn test_get_ai_guidance_status_reports_custom_and_repairable_files() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "# Custom Agent Config\nHands off\n");
|
||||
write_root_claude(&vault, "");
|
||||
|
||||
let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(status.agents_state, AiGuidanceFileState::Custom);
|
||||
assert_eq!(status.claude_state, AiGuidanceFileState::Broken);
|
||||
assert!(status.can_restore);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_ai_guidance_files_repairs_without_overwriting_custom_agents() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "# Custom Agent Config\nHands off\n");
|
||||
write_root_claude(&vault, "");
|
||||
|
||||
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(status.agents_state, AiGuidanceFileState::Custom);
|
||||
assert_eq!(status.claude_state, AiGuidanceFileState::Managed);
|
||||
assert!(!status.can_restore);
|
||||
assert!(read_root_agents(&vault).contains("Custom Agent Config"));
|
||||
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_ai_guidance_files_repairs_broken_agents_and_missing_claude() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "");
|
||||
|
||||
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(status.agents_state, AiGuidanceFileState::Managed);
|
||||
assert_eq!(status.claude_state, AiGuidanceFileState::Managed);
|
||||
assert!(!status.can_restore);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,17 +21,22 @@ pub(crate) struct Frontmatter {
|
||||
pub archived: Option<bool>,
|
||||
#[serde(rename = "Status", alias = "status", default)]
|
||||
pub status: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "_icon", alias = "icon", default)]
|
||||
pub icon: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub color: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "_order", alias = "order", default)]
|
||||
pub order: Option<i64>,
|
||||
#[serde(rename = "sidebar label", default)]
|
||||
#[serde(
|
||||
rename = "_sidebar_label",
|
||||
alias = "sidebar label",
|
||||
alias = "sidebar_label",
|
||||
default
|
||||
)]
|
||||
pub sidebar_label: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub template: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "_sort", alias = "sort", default)]
|
||||
pub sort: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub view: Option<StringOrList>,
|
||||
@@ -147,28 +152,28 @@ impl StringOrList {
|
||||
///
|
||||
/// This sanitizer converts objects back to "key: value" strings and removes nulls,
|
||||
/// preventing serde deserialization of the entire Frontmatter struct from failing.
|
||||
fn sanitize_array_item(item: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
match item {
|
||||
serde_json::Value::Null => None,
|
||||
serde_json::Value::Object(map) if !map.is_empty() => {
|
||||
let parts: Vec<String> = map
|
||||
.iter()
|
||||
.map(|(k, v)| match v {
|
||||
serde_json::Value::String(s) => format!("{}: {}", k, s),
|
||||
_ => format!("{}: {}", k, v),
|
||||
})
|
||||
.collect();
|
||||
Some(serde_json::Value::String(parts.join(", ")))
|
||||
}
|
||||
other => Some(other.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_value(value: &serde_json::Value) -> serde_json::Value {
|
||||
match value {
|
||||
serde_json::Value::Array(arr) => {
|
||||
let sanitized: Vec<serde_json::Value> = arr
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
// Drop nulls (from `# comment` in list items)
|
||||
serde_json::Value::Null => None,
|
||||
// Convert mis-parsed objects back to "key: value" strings
|
||||
serde_json::Value::Object(map) if !map.is_empty() => {
|
||||
let parts: Vec<String> = map
|
||||
.iter()
|
||||
.map(|(k, v)| match v {
|
||||
serde_json::Value::String(s) => format!("{}: {}", k, s),
|
||||
_ => format!("{}: {}", k, v),
|
||||
})
|
||||
.collect();
|
||||
Some(serde_json::Value::String(parts.join(", ")))
|
||||
}
|
||||
other => Some(other.clone()),
|
||||
})
|
||||
.collect();
|
||||
let sanitized: Vec<serde_json::Value> =
|
||||
arr.iter().filter_map(sanitize_array_item).collect();
|
||||
serde_json::Value::Array(sanitized)
|
||||
}
|
||||
other => other.clone(),
|
||||
@@ -186,11 +191,16 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"_archived",
|
||||
"Archived",
|
||||
"archived",
|
||||
"_icon",
|
||||
"icon",
|
||||
"color",
|
||||
"_order",
|
||||
"order",
|
||||
"_sidebar_label",
|
||||
"sidebar_label",
|
||||
"sidebar label",
|
||||
"template",
|
||||
"_sort",
|
||||
"sort",
|
||||
"view",
|
||||
"visible",
|
||||
@@ -216,7 +226,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
/// Note: owner and cadence are NOT skipped — they should appear in generic properties.
|
||||
const SKIP_KEYS: &[&str] = &[
|
||||
"title",
|
||||
"is a",
|
||||
"is_a",
|
||||
"type",
|
||||
"aliases",
|
||||
"_archived",
|
||||
@@ -224,7 +234,7 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
"sidebar label",
|
||||
"sidebar_label",
|
||||
"template",
|
||||
"sort",
|
||||
"view",
|
||||
@@ -236,6 +246,20 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"_list_properties_display",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct FrontmatterKey<'a>(&'a str);
|
||||
|
||||
impl FrontmatterKey<'_> {
|
||||
fn normalize(self) -> String {
|
||||
self.0.trim().to_ascii_lowercase().replace(' ', "_")
|
||||
}
|
||||
|
||||
fn should_skip(self) -> bool {
|
||||
let normalized = self.normalize();
|
||||
normalized.starts_with('_') || SKIP_KEYS.contains(&normalized.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract all wikilink-containing fields from raw YAML frontmatter.
|
||||
pub(crate) fn extract_relationships(
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
@@ -243,15 +267,13 @@ pub(crate) fn extract_relationships(
|
||||
let mut relationships = HashMap::new();
|
||||
|
||||
for (key, value) in data {
|
||||
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) {
|
||||
if FrontmatterKey(key).should_skip() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match value {
|
||||
serde_json::Value::String(s) => {
|
||||
if contains_wikilink(s) {
|
||||
relationships.insert(key.clone(), vec![s.clone()]);
|
||||
}
|
||||
serde_json::Value::String(s) if contains_wikilink(s) => {
|
||||
relationships.insert(key.clone(), vec![s.clone()]);
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
let wikilinks: Vec<String> = arr
|
||||
@@ -278,16 +300,13 @@ pub(crate) fn extract_properties(
|
||||
let mut properties = HashMap::new();
|
||||
|
||||
for (key, value) in data {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&lower)) {
|
||||
if FrontmatterKey(key).should_skip() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match value {
|
||||
serde_json::Value::String(s) => {
|
||||
if !contains_wikilink(s) {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
serde_json::Value::String(s) if !contains_wikilink(s) => {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
serde_json::Value::Number(_) | serde_json::Value::Bool(_) => {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
@@ -295,11 +314,9 @@ pub(crate) fn extract_properties(
|
||||
// Handle single-element arrays: unwrap to scalar.
|
||||
// This ensures YAML like "Owner: [Luca]" or "Owner:\n - Luca" works correctly.
|
||||
serde_json::Value::Array(arr) => {
|
||||
if arr.len() == 1 {
|
||||
if let Some(serde_json::Value::String(s)) = arr.first() {
|
||||
if !contains_wikilink(s) {
|
||||
properties.insert(key.clone(), serde_json::Value::String(s.clone()));
|
||||
}
|
||||
if let [serde_json::Value::String(s)] = arr.as_slice() {
|
||||
if !contains_wikilink(s) {
|
||||
properties.insert(key.clone(), serde_json::Value::String(s.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,38 +351,117 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip matching outer quotes (single or double) from a YAML scalar.
|
||||
fn unquote(s: &str) -> &str {
|
||||
s.strip_prefix('"')
|
||||
.and_then(|rest| rest.strip_suffix('"'))
|
||||
.or_else(|| {
|
||||
s.strip_prefix('\'')
|
||||
.and_then(|rest| rest.strip_suffix('\''))
|
||||
})
|
||||
.unwrap_or(s)
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct YamlScalar<'a>(&'a str);
|
||||
|
||||
/// Parse a scalar YAML value into a JSON value.
|
||||
fn parse_scalar(s: &str) -> serde_json::Value {
|
||||
let trimmed = unquote(s);
|
||||
match trimmed.to_lowercase().as_str() {
|
||||
"true" | "yes" => serde_json::Value::Bool(true),
|
||||
"false" | "no" => serde_json::Value::Bool(false),
|
||||
_ => trimmed
|
||||
.parse::<i64>()
|
||||
.map(|n| serde_json::json!(n))
|
||||
.unwrap_or_else(|_| serde_json::Value::String(trimmed.to_string())),
|
||||
impl<'a> YamlScalar<'a> {
|
||||
/// Strip matching outer quotes (single or double) from a YAML scalar.
|
||||
fn unquote(self) -> &'a str {
|
||||
self.0
|
||||
.strip_prefix('"')
|
||||
.and_then(|rest| rest.strip_suffix('"'))
|
||||
.or_else(|| {
|
||||
self.0
|
||||
.strip_prefix('\'')
|
||||
.and_then(|rest| rest.strip_suffix('\''))
|
||||
})
|
||||
.unwrap_or(self.0)
|
||||
}
|
||||
|
||||
/// Parse a scalar YAML value into a JSON value.
|
||||
fn parse(self) -> serde_json::Value {
|
||||
let trimmed = self.unquote();
|
||||
match trimmed.to_lowercase().as_str() {
|
||||
"true" | "yes" => serde_json::Value::Bool(true),
|
||||
"false" | "no" => serde_json::Value::Bool(false),
|
||||
_ => trimmed
|
||||
.parse::<i64>()
|
||||
.map(|n| serde_json::json!(n))
|
||||
.unwrap_or_else(|_| serde_json::Value::String(trimmed.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the key from a top-level `key:` or `"key":` YAML line.
|
||||
/// Returns `None` for indented, blank, or non-key lines.
|
||||
fn extract_yaml_key(line: &str) -> Option<&str> {
|
||||
if line.is_empty() || line.starts_with(' ') || line.starts_with('\t') {
|
||||
return None;
|
||||
#[derive(Clone, Copy)]
|
||||
struct YamlLine<'a>(&'a str);
|
||||
|
||||
impl<'a> YamlLine<'a> {
|
||||
fn is_top_level(self) -> bool {
|
||||
if self.0.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if self.0.starts_with(' ') {
|
||||
return false;
|
||||
}
|
||||
!self.0.starts_with('\t')
|
||||
}
|
||||
|
||||
/// Return the key from a top-level `key:` or `"key":` YAML line.
|
||||
/// Returns `None` for indented, blank, or non-key lines.
|
||||
fn key(self) -> Option<&'a str> {
|
||||
if !self.is_top_level() {
|
||||
return None;
|
||||
}
|
||||
let (key, _) = self.0.split_once(':')?;
|
||||
Some(key.trim().trim_matches('"'))
|
||||
}
|
||||
|
||||
fn list_item(self) -> Option<&'a str> {
|
||||
self.0.strip_prefix(" - ").map(str::trim)
|
||||
}
|
||||
|
||||
fn value_part(self) -> Option<&'a str> {
|
||||
self.0.split_once(':').map(|(_, value)| value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct RawFrontmatter<'a>(&'a str);
|
||||
|
||||
impl<'a> RawFrontmatter<'a> {
|
||||
fn extract_block(self) -> Option<&'a str> {
|
||||
let rest = self.0.strip_prefix("---")?;
|
||||
let rest = rest
|
||||
.strip_prefix('\n')
|
||||
.or_else(|| rest.strip_prefix("\r\n"))?;
|
||||
let end = rest.find("\n---")?;
|
||||
Some(&rest[..end])
|
||||
}
|
||||
|
||||
/// Fallback parser for when gray_matter fails to parse YAML (returns raw string).
|
||||
/// Extracts simple `key: value` lines, handling booleans, numbers, quoted strings,
|
||||
/// and YAML lists.
|
||||
fn parse_fallback(self) -> HashMap<String, serde_json::Value> {
|
||||
let mut map = HashMap::new();
|
||||
let mut list_key: Option<String> = None;
|
||||
let mut list_items: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
for line in self.0.lines() {
|
||||
let yaml_line = YamlLine(line);
|
||||
|
||||
// Accumulate list items under the current key
|
||||
if list_key.is_some() {
|
||||
if let Some(item) = yaml_line.list_item() {
|
||||
list_items.push(YamlScalar(item).parse());
|
||||
continue;
|
||||
}
|
||||
flush_list(&mut map, &mut list_key, &mut list_items);
|
||||
}
|
||||
|
||||
let Some(key) = yaml_line.key() else {
|
||||
continue;
|
||||
};
|
||||
let value_part = yaml_line.value_part().unwrap_or("");
|
||||
if value_part.is_empty() {
|
||||
list_key = Some(key.to_string());
|
||||
} else {
|
||||
map.insert(key.to_string(), YamlScalar(value_part).parse());
|
||||
}
|
||||
}
|
||||
|
||||
flush_list(&mut map, &mut list_key, &mut list_items);
|
||||
map
|
||||
}
|
||||
let (k, _) = line.split_once(':')?;
|
||||
Some(k.trim().trim_matches('"'))
|
||||
}
|
||||
|
||||
/// Flush a pending list accumulator into the map.
|
||||
@@ -381,48 +477,6 @@ fn flush_list(
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback parser for when gray_matter fails to parse YAML (returns raw string).
|
||||
/// Extracts simple `key: value` lines, handling booleans, numbers, quoted strings,
|
||||
/// and YAML lists.
|
||||
fn fallback_parse_yaml_string(raw: &str) -> HashMap<String, serde_json::Value> {
|
||||
let mut map = HashMap::new();
|
||||
let mut list_key: Option<String> = None;
|
||||
let mut list_items: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
for line in raw.lines() {
|
||||
// Accumulate list items under the current key
|
||||
if list_key.is_some() {
|
||||
if let Some(item) = line.strip_prefix(" - ") {
|
||||
list_items.push(parse_scalar(item.trim()));
|
||||
continue;
|
||||
}
|
||||
flush_list(&mut map, &mut list_key, &mut list_items);
|
||||
}
|
||||
|
||||
let Some(key) = extract_yaml_key(line) else {
|
||||
continue;
|
||||
};
|
||||
let value_part = line.split_once(':').map(|(_, v)| v.trim()).unwrap_or("");
|
||||
if value_part.is_empty() {
|
||||
list_key = Some(key.to_string());
|
||||
} else {
|
||||
map.insert(key.to_string(), parse_scalar(value_part));
|
||||
}
|
||||
}
|
||||
flush_list(&mut map, &mut list_key, &mut list_items);
|
||||
map
|
||||
}
|
||||
|
||||
/// Extract the raw YAML frontmatter string from between `---` delimiters.
|
||||
fn extract_raw_frontmatter(content: &str) -> Option<&str> {
|
||||
let rest = content.strip_prefix("---")?;
|
||||
let rest = rest
|
||||
.strip_prefix('\n')
|
||||
.or_else(|| rest.strip_prefix("\r\n"))?;
|
||||
let end = rest.find("\n---")?;
|
||||
Some(&rest[..end])
|
||||
}
|
||||
|
||||
/// Extract frontmatter, relationships, and custom properties from parsed gray_matter data.
|
||||
/// When gray_matter fails to parse YAML (e.g. malformed quotes from Notion exports),
|
||||
/// `raw_content` is used as a fallback: simple key:value pairs are extracted line-by-line
|
||||
@@ -442,8 +496,8 @@ pub(crate) fn extract_fm_and_rels(
|
||||
_ => {
|
||||
// gray_matter returned Null, String, or None — YAML parse failed.
|
||||
// Fall back to line-by-line extraction from the raw frontmatter block.
|
||||
match extract_raw_frontmatter(raw_content) {
|
||||
Some(raw) => fallback_parse_yaml_string(raw),
|
||||
match RawFrontmatter(raw_content).extract_block() {
|
||||
Some(raw) => RawFrontmatter(raw).parse_fallback(),
|
||||
None => return (Frontmatter::default(), HashMap::new(), HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
135
src-tauri/src/vault/frontmatter_regression_tests.rs
Normal file
135
src-tauri/src/vault/frontmatter_regression_tests.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_file(dir: &Path, name: &str, content: &str) {
|
||||
let file_path = dir.join(name);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
let mut file = fs::File::create(file_path).unwrap();
|
||||
file.write_all(content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
fn parse_test_entry(dir: &TempDir, name: &str, content: &str) -> VaultEntry {
|
||||
create_test_file(dir.path(), name, content);
|
||||
parse_md_file(&dir.path().join(name), None).unwrap()
|
||||
}
|
||||
|
||||
struct HiddenPropertyCase<'a> {
|
||||
content: &'a str,
|
||||
hidden_key: &'a str,
|
||||
}
|
||||
|
||||
fn assert_filtered_property_stays_hidden(case: HiddenPropertyCase<'_>) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(&dir, "test.md", case.content);
|
||||
assert!(!entry.properties.contains_key(case.hidden_key));
|
||||
assert_eq!(
|
||||
entry
|
||||
.properties
|
||||
.get("Company")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("Acme Corp")
|
||||
);
|
||||
}
|
||||
|
||||
struct SingleElementArrayCase<'a> {
|
||||
note_type: &'a str,
|
||||
key: &'a str,
|
||||
value: &'a str,
|
||||
}
|
||||
|
||||
fn assert_single_element_array_property(case: SingleElementArrayCase<'_>) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = format!(
|
||||
"---\ntype: {}\n{}:\n - {}\n---\n# Test\n",
|
||||
case.note_type, case.key, case.value
|
||||
);
|
||||
let entry = parse_test_entry(&dir, "test.md", &content);
|
||||
assert_eq!(
|
||||
entry
|
||||
.properties
|
||||
.get(case.key)
|
||||
.and_then(|property| property.as_str()),
|
||||
Some(case.value)
|
||||
);
|
||||
assert_eq!(entry.is_a, Some(case.note_type.to_string()));
|
||||
}
|
||||
|
||||
struct AliasRecoveryCase<'a> {
|
||||
file_name: &'a str,
|
||||
title: &'a str,
|
||||
alias_item: &'a str,
|
||||
}
|
||||
|
||||
fn assert_alias_parser_recovers(case: AliasRecoveryCase<'_>) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = format!(
|
||||
"---\ntype: Note\n_organized: true\naliases:\n - {}\n - Note\n---\n# {}\n",
|
||||
case.alias_item, case.title
|
||||
);
|
||||
let entry = parse_test_entry(&dir, case.file_name, &content);
|
||||
assert_eq!(entry.is_a, Some("Note".to_string()));
|
||||
assert!(entry.organized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filtered_properties_stay_hidden() {
|
||||
let cases = [
|
||||
HiddenPropertyCase {
|
||||
content: "---\nMentor: \"[[person/alice]]\"\nCompany: Acme Corp\n---\n# Test\n",
|
||||
hidden_key: "Mentor",
|
||||
},
|
||||
HiddenPropertyCase {
|
||||
content: "---\nTags:\n - productivity\n - writing\nCompany: Acme Corp\n---\n# Test\n",
|
||||
hidden_key: "Tags",
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
assert_filtered_property_stays_hidden(case);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_element_array_properties_unwrap_to_scalars() {
|
||||
let cases = [
|
||||
SingleElementArrayCase {
|
||||
note_type: "Responsibility",
|
||||
key: "Owner",
|
||||
value: "Luca",
|
||||
},
|
||||
SingleElementArrayCase {
|
||||
note_type: "Procedure",
|
||||
key: "Cadence",
|
||||
value: "Weekly",
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
assert_single_element_array_property(case);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alias_parser_recovers_special_alias_items() {
|
||||
let cases = [
|
||||
AliasRecoveryCase {
|
||||
file_name: "colon-alias.md",
|
||||
title: "Test",
|
||||
alias_item: "Bitcoin: Net Unrealized Profit/Loss",
|
||||
},
|
||||
AliasRecoveryCase {
|
||||
file_name: "hash-alias.md",
|
||||
title: "Title",
|
||||
alias_item: "# Writing a Good CLAUDE.md",
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
assert_alias_parser_recovers(case);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Public starter vault cloned when the user chooses Getting Started.
|
||||
pub const GETTING_STARTED_REPO_URL: &str =
|
||||
"https://github.com/refactoringhq/laputa-getting-started.git";
|
||||
"https://github.com/refactoringhq/tolaria-getting-started.git";
|
||||
|
||||
/// Default location for the Getting Started vault.
|
||||
pub fn default_vault_path() -> Result<PathBuf, String> {
|
||||
@@ -16,10 +18,108 @@ pub fn vault_exists(path: &str) -> bool {
|
||||
Path::new(path).is_dir()
|
||||
}
|
||||
|
||||
/// Default AGENTS.md content — vault instructions for AI agents.
|
||||
/// Describes Laputa vault mechanics only; no vault-specific structure.
|
||||
/// The vault scanner will pick this up as a regular entry.
|
||||
pub(super) const AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
|
||||
/// Previous default AGENTS.md content seeded by Tolaria itself. Existing vaults
|
||||
/// can still contain this exact text, so Tolaria treats it as managed content
|
||||
/// that is safe to refresh automatically.
|
||||
const STALE_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
|
||||
|
||||
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault - a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
|
||||
Keep edits compatible with Tolaria's current conventions. Prefer small, human-readable changes over heavy restructuring.
|
||||
|
||||
## Core rules
|
||||
|
||||
- One markdown note per file.
|
||||
- The first H1 in the body is the note title. Do not add `title:` frontmatter.
|
||||
- Most notes live at the vault root as flat `.md` files. Type definitions live in `type/`. Saved views live in `views/`.
|
||||
- Use wikilinks for note-to-note references, both in frontmatter and in the body.
|
||||
- Frontmatter properties that start with `_` are usually Tolaria-managed state. Leave them alone unless the user explicitly asks for them to change.
|
||||
|
||||
## Notes
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Project
|
||||
status: Active
|
||||
belongs_to:
|
||||
- "[[area-operations]]"
|
||||
related_to:
|
||||
- "[[goal-q2-launch]]"
|
||||
---
|
||||
|
||||
# Q2 Launch Plan
|
||||
|
||||
Body content in markdown.
|
||||
```
|
||||
|
||||
Tolaria still understands some legacy aliases such as `Is A`, but prefer `type:` for new or edited notes.
|
||||
|
||||
## Types
|
||||
|
||||
Type definitions are regular notes stored in `type/`. Use `type: Type` in frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
icon: books
|
||||
color: blue
|
||||
order: 20
|
||||
sidebar label: Projects
|
||||
---
|
||||
|
||||
# Project
|
||||
```
|
||||
|
||||
Useful type metadata includes `icon`, `color`, `order`, `sidebar label`, `template`, `sort`, `view`, and `visible`.
|
||||
|
||||
## Relationships
|
||||
|
||||
Any frontmatter property whose value is a wikilink is treated as a relationship. Common names include `belongs_to`, `related_to`, and `has`, but custom relationship names are valid too.
|
||||
|
||||
## Wikilinks
|
||||
|
||||
- `[[filename]]` or `[[Note Title]]` - link by filename or title
|
||||
- `[[filename|display text]]` - with custom display text
|
||||
- Works in frontmatter values and markdown body
|
||||
|
||||
## Views
|
||||
|
||||
Saved filters live in `views/` as `.view.json` files:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Active Notes",
|
||||
"filters": [
|
||||
{"property": "type", "operator": "equals", "value": "Note"},
|
||||
{"property": "status", "operator": "equals", "value": "Active"}
|
||||
],
|
||||
"sort": {"property": "title", "direction": "asc"}
|
||||
}
|
||||
```
|
||||
|
||||
## Filenames
|
||||
|
||||
Use kebab-case: `my-note-title.md`. One note per file.
|
||||
|
||||
## What agents should do
|
||||
|
||||
- Create and edit notes using the frontmatter and H1 conventions above.
|
||||
- Create and edit type documents in `type/`.
|
||||
- Add or modify relationships without breaking existing wikilinks.
|
||||
- Create and edit saved views in `views/`.
|
||||
- Update `AGENTS.md` only when the user asks for agent guidance changes.
|
||||
|
||||
## What agents should avoid
|
||||
|
||||
- Do not infer note type from folders other than the dedicated `type/` directory for type definitions.
|
||||
- Do not silently overwrite an existing custom `AGENTS.md`.
|
||||
- Do not rewrite installation-specific app config unless the user explicitly asks.
|
||||
"##;
|
||||
|
||||
/// Older Tolaria-managed AGENTS.md content from before the `type:` migration.
|
||||
/// Existing vaults can still contain this exact text, so Tolaria treats it as
|
||||
/// managed content that is safe to refresh automatically.
|
||||
const PRE_TYPE_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
|
||||
|
||||
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
|
||||
@@ -102,6 +202,215 @@ Use kebab-case: `my-note-title.md`. One note per file.
|
||||
Do not modify app configuration files — those are local to each installation.
|
||||
"##;
|
||||
|
||||
const OUTDATED_AGENTS_MARKERS: [&str; 3] = [
|
||||
"# AGENTS.md — Tolaria Vault",
|
||||
"Legacy `title:` frontmatter is still read as a fallback",
|
||||
"Tolaria still understands legacy aliases such as `Is A`.",
|
||||
];
|
||||
|
||||
pub(super) fn agents_content_can_be_refreshed(content: &str) -> bool {
|
||||
let is_outdated_managed_template = OUTDATED_AGENTS_MARKERS
|
||||
.iter()
|
||||
.all(|marker| content.contains(marker));
|
||||
let is_stale_title_stub = content.contains("Do not add `title:` frontmatter.");
|
||||
|
||||
content.trim().is_empty()
|
||||
|| content == PRE_TYPE_AGENTS_MD
|
||||
|| content == LEGACY_AGENTS_MD
|
||||
|| content == STALE_AGENTS_MD
|
||||
|| is_stale_title_stub
|
||||
|| is_outdated_managed_template
|
||||
}
|
||||
|
||||
/// Default AGENTS.md content — vault instructions for AI agents.
|
||||
/// Describes Tolaria vault mechanics only; no user-specific structure.
|
||||
/// The vault scanner will pick this up as a regular entry.
|
||||
pub(super) const AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
|
||||
|
||||
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault: a folder of Markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
|
||||
Keep edits compatible with Tolaria's current conventions. Prefer small, human-readable changes over heavy restructuring.
|
||||
|
||||
## Core conventions
|
||||
|
||||
- One Markdown note per file.
|
||||
- The first H1 in the body is the preferred display title.
|
||||
- Legacy `title:` frontmatter is still read as a fallback when a note has no H1. Do not add it to new notes unless you are maintaining an older file.
|
||||
- Store note type in the `type:` frontmatter field.
|
||||
- Most notes live at the vault root as flat `.md` files. Type definitions live in `type/`. Saved views live in `views/`.
|
||||
- Any frontmatter field containing `[[wikilinks]]` is treated as a relationship. Common names include `Belongs to:`, `Related to:`, `Workspace:`, and custom relationship names.
|
||||
- Frontmatter properties that start with `_` are usually Tolaria-managed state. Leave them alone unless the user explicitly asks for them to change.
|
||||
|
||||
## Notes
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Project
|
||||
status: Active
|
||||
icon: target
|
||||
Workspace: "[[tolaria]]"
|
||||
Belongs to:
|
||||
- "[[25q2]]"
|
||||
Related to:
|
||||
- "[[person-luca-rossi]]"
|
||||
aliases:
|
||||
- Tolaria work
|
||||
url: https://example.com
|
||||
---
|
||||
|
||||
# Ship Tolaria
|
||||
|
||||
Body content in Markdown.
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
Type definitions are regular notes stored in `type/`. Use `type: Type` for new ones:
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
icon: shapes
|
||||
color: blue
|
||||
sidebar label: Projects
|
||||
template: |
|
||||
## Outcome
|
||||
|
||||
## Next actions
|
||||
---
|
||||
|
||||
# Project
|
||||
```
|
||||
|
||||
Useful type metadata includes `icon`, `color`, `order`, `sidebar label`, `template`, `sort`, `view`, and `visible`.
|
||||
|
||||
## Wikilinks
|
||||
|
||||
- `[[filename]]` or `[[Note Title]]` — link by filename or title
|
||||
- `[[filename|display text]]` — with custom display text
|
||||
- Works in frontmatter values and Markdown body
|
||||
|
||||
## Views
|
||||
|
||||
Saved views live in `views/*.yml` and are written as YAML:
|
||||
|
||||
```yaml
|
||||
name: Active Projects
|
||||
icon: kanban
|
||||
color: blue
|
||||
sort: modified:desc
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
- field: status
|
||||
op: equals
|
||||
value: Active
|
||||
```
|
||||
|
||||
## Filenames
|
||||
|
||||
Use kebab-case: `my-note-title.md`. One note per file.
|
||||
|
||||
## What agents should do
|
||||
|
||||
- Create and edit notes using the frontmatter and H1 conventions above.
|
||||
- Create and edit type documents in `type/`.
|
||||
- Add or modify relationships without breaking existing wikilinks.
|
||||
- Create and edit saved views in `views/`.
|
||||
- Update `AGENTS.md` only when the user asks for vault-level guidance changes.
|
||||
|
||||
## What agents should avoid
|
||||
|
||||
- Do not infer note type from folders other than the dedicated `type/` directory for type definitions.
|
||||
- Do not silently overwrite an existing custom `AGENTS.md`.
|
||||
- Do not overwrite user-authored config or installation-specific app files unless the user explicitly asks.
|
||||
"##;
|
||||
|
||||
pub(super) const LEGACY_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
|
||||
|
||||
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
|
||||
## Note structure
|
||||
|
||||
Every note is a markdown file. The **first H1 heading in the body is the title** — there is no `title:` frontmatter field.
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: TypeName # the note's type (must match the title of a type file in the vault)
|
||||
url: https://... # example property
|
||||
belongs_to: "[[other-note]]"
|
||||
related_to:
|
||||
- "[[note-a]]"
|
||||
- "[[note-b]]"
|
||||
---
|
||||
|
||||
# Note Title
|
||||
|
||||
Body content in markdown.
|
||||
```
|
||||
|
||||
System properties are prefixed with `_` (e.g. `_organized`, `_pinned`, `_icon`) — these are app-managed, do not set or show them to users unless specifically asked.
|
||||
|
||||
## Types
|
||||
|
||||
A type is a note with `type: Type`. Type files live in the vault root:
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
_icon: books # Phosphor icon name in kebab-case
|
||||
_color: "#8b5cf6" # hex color
|
||||
---
|
||||
|
||||
# TypeName
|
||||
```
|
||||
|
||||
To find what types exist: look for files with `type: Type` in the vault root.
|
||||
|
||||
## Relationships
|
||||
|
||||
Any frontmatter property whose value is a wikilink is a relationship. Backlinks are computed automatically.
|
||||
|
||||
Standard names: `belongs_to`, `related_to`, `has`. Custom names are valid.
|
||||
|
||||
## Wikilinks
|
||||
|
||||
- `[[filename]]` or `[[Note Title]]` — link by filename or title
|
||||
- `[[filename|display text]]` — with custom display text
|
||||
- Works in frontmatter values and markdown body
|
||||
|
||||
## Views
|
||||
|
||||
Saved filters live in `views/` as `.view.json` files:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Active Notes",
|
||||
"filters": [
|
||||
{"property": "type", "operator": "equals", "value": "Note"},
|
||||
{"property": "status", "operator": "equals", "value": "Active"}
|
||||
],
|
||||
"sort": {"property": "title", "direction": "asc"}
|
||||
}
|
||||
```
|
||||
|
||||
## Filenames
|
||||
|
||||
Use kebab-case: `my-note-title.md`. One note per file.
|
||||
|
||||
## What you can do
|
||||
|
||||
- Create/edit notes with correct frontmatter and H1 title
|
||||
- Create new type files
|
||||
- Add or modify relationships
|
||||
- Create/edit views in `views/`
|
||||
- Edit `AGENTS.md` (this file)
|
||||
|
||||
Do not modify app configuration files — those are local to each installation.
|
||||
"##;
|
||||
|
||||
/// Clone the public starter vault into the requested path.
|
||||
pub fn create_getting_started_vault(target_path: &str) -> Result<String, String> {
|
||||
create_getting_started_vault_from_repo(target_path, &getting_started_repo_url())
|
||||
@@ -115,8 +424,10 @@ fn create_getting_started_vault_from_repo(
|
||||
return Err("Target path is required".to_string());
|
||||
}
|
||||
|
||||
crate::github::clone_public_repo(repo_url, target_path)?;
|
||||
canonical_vault_path(target_path)
|
||||
crate::git::clone_repo(repo_url, target_path)?;
|
||||
let vault_path = canonical_vault_path(target_path)?;
|
||||
refresh_cloned_vault_config_files(&vault_path)?;
|
||||
Ok(vault_path)
|
||||
}
|
||||
|
||||
fn getting_started_repo_url() -> String {
|
||||
@@ -133,6 +444,81 @@ fn canonical_vault_path(target_path: &str) -> Result<String, String> {
|
||||
Ok(canonical.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn refresh_cloned_vault_config_files(vault_path: &str) -> Result<(), String> {
|
||||
let agents_path = Path::new(vault_path).join("AGENTS.md");
|
||||
let refresh_agents = if !agents_path.exists() {
|
||||
true
|
||||
} else {
|
||||
let content = fs::read_to_string(&agents_path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", agents_path.display()))?;
|
||||
agents_content_can_be_refreshed(&content)
|
||||
};
|
||||
|
||||
if refresh_agents {
|
||||
fs::write(&agents_path, AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", agents_path.display()))?;
|
||||
}
|
||||
|
||||
crate::vault::repair_config_files(vault_path)?;
|
||||
|
||||
if !vault_has_pending_changes(vault_path)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ensure_commit_identity(vault_path)?;
|
||||
crate::git::git_commit(vault_path, "Initialize Tolaria config files")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vault_has_pending_changes(vault_path: &str) -> Result<bool, String> {
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to inspect cloned vault status: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"git status failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
))
|
||||
}
|
||||
|
||||
fn ensure_commit_identity(vault_path: &str) -> Result<(), String> {
|
||||
for (key, fallback) in [
|
||||
("user.name", "Tolaria"),
|
||||
("user.email", "vault@tolaria.app"),
|
||||
] {
|
||||
let output = Command::new("git")
|
||||
.args(["config", key])
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to inspect git config {key}: {e}"))?;
|
||||
|
||||
if output.status.success() && !String::from_utf8_lossy(&output.stdout).trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let set_output = Command::new("git")
|
||||
.args(["config", key, fallback])
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to set git config {key}: {e}"))?;
|
||||
|
||||
if !set_output.status.success() {
|
||||
return Err(format!(
|
||||
"git config {key} failed: {}",
|
||||
String::from_utf8_lossy(&set_output.stderr).trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -140,7 +526,7 @@ mod tests {
|
||||
use std::path::Path;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
fn init_source_repo(path: &Path) {
|
||||
fn init_source_repo(path: &Path, agents_content: Option<&str>) {
|
||||
fs::create_dir_all(path.join("views")).unwrap();
|
||||
fs::write(
|
||||
path.join("welcome.md"),
|
||||
@@ -152,6 +538,9 @@ mod tests {
|
||||
"title: Active Projects\nfilters: []\n",
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(content) = agents_content {
|
||||
fs::write(path.join("AGENTS.md"), content).unwrap();
|
||||
}
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
@@ -180,6 +569,20 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn assert_getting_started_vault_replaces_template(agents_content: &str) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("starter");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source, Some(agents_content));
|
||||
|
||||
create_getting_started_vault_from_repo(dest.to_str().unwrap(), source.to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
let content = fs::read_to_string(dest.join("AGENTS.md")).unwrap();
|
||||
assert_eq!(content, AGENTS_MD);
|
||||
assert!(dest.join("config.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_vault_path_appends_getting_started() {
|
||||
let path = default_vault_path().unwrap();
|
||||
@@ -187,12 +590,20 @@ mod tests {
|
||||
assert!(path_str.ends_with("Getting Started"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_getting_started_repo_url_uses_tolaria_slug() {
|
||||
assert_eq!(
|
||||
GETTING_STARTED_REPO_URL,
|
||||
"https://github.com/refactoringhq/tolaria-getting-started.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_clones_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("starter");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source);
|
||||
init_source_repo(&source, None);
|
||||
|
||||
let result = create_getting_started_vault_from_repo(
|
||||
dest.to_str().unwrap(),
|
||||
@@ -204,6 +615,11 @@ mod tests {
|
||||
assert!(dest.join("welcome.md").exists());
|
||||
assert!(dest.join("views").join("active-projects.yml").exists());
|
||||
assert!(dest.join(".git").exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(dest.join("AGENTS.md")).unwrap(),
|
||||
AGENTS_MD
|
||||
);
|
||||
assert!(dest.join("config.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -211,7 +627,7 @@ mod tests {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("starter");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source);
|
||||
init_source_repo(&source, None);
|
||||
fs::create_dir_all(&dest).unwrap();
|
||||
fs::write(dest.join("existing.md"), "# Existing\n").unwrap();
|
||||
|
||||
@@ -245,7 +661,7 @@ mod tests {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("starter");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source);
|
||||
init_source_repo(&source, None);
|
||||
|
||||
create_getting_started_vault_from_repo(dest.to_str().unwrap(), source.to_str().unwrap())
|
||||
.unwrap();
|
||||
@@ -257,4 +673,34 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_replaces_legacy_agents_template() {
|
||||
assert_getting_started_vault_replaces_template(LEGACY_AGENTS_MD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_replaces_pre_type_agents_template() {
|
||||
assert_getting_started_vault_replaces_template(PRE_TYPE_AGENTS_MD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_refresh_detection_accepts_pre_type_managed_template() {
|
||||
assert!(agents_content_can_be_refreshed(PRE_TYPE_AGENTS_MD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_template_matches_current_tolaria_vault_conventions() {
|
||||
assert!(AGENTS_MD.contains("# AGENTS.md — Tolaria Vault"));
|
||||
assert!(AGENTS_MD.contains("type/"));
|
||||
assert!(AGENTS_MD.contains("views/"));
|
||||
assert!(AGENTS_MD.contains("sidebar label"));
|
||||
assert!(AGENTS_MD.contains("Legacy `title:` frontmatter is still read as a fallback"));
|
||||
assert!(AGENTS_MD.contains("Store note type in the `type:` frontmatter field."));
|
||||
assert!(AGENTS_MD.contains("views/*.yml"));
|
||||
assert!(AGENTS_MD.contains("Belongs to:"));
|
||||
assert!(!AGENTS_MD.contains("Laputa"));
|
||||
assert!(!AGENTS_MD.contains("Is A"));
|
||||
assert!(!AGENTS_MD.contains("is_a"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ mod trash;
|
||||
mod views;
|
||||
|
||||
pub use cache::{invalidate_cache, scan_vault_cached};
|
||||
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
|
||||
pub use config_seed::{
|
||||
get_ai_guidance_status, migrate_agents_md, repair_config_files, restore_ai_guidance_files,
|
||||
seed_config_files, AiGuidanceFileState, VaultAiGuidanceStatus,
|
||||
};
|
||||
pub use entry::{FolderNode, VaultEntry};
|
||||
pub use file::{get_note_content, save_note_content};
|
||||
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
|
||||
@@ -40,6 +43,18 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
fn preferred_relationship_refs(
|
||||
relationships: &std::collections::HashMap<String, Vec<String>>,
|
||||
canonical_key: &str,
|
||||
legacy_key: &str,
|
||||
) -> Vec<String> {
|
||||
relationships
|
||||
.get(canonical_key)
|
||||
.cloned()
|
||||
.or_else(|| relationships.get(legacy_key).cloned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn derive_markdown_title_from_content(content: &str, filename: &str) -> String {
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(content);
|
||||
@@ -47,11 +62,26 @@ pub(crate) fn derive_markdown_title_from_content(content: &str, filename: &str)
|
||||
extract_title(frontmatter.title.as_deref(), content, filename)
|
||||
}
|
||||
|
||||
fn resolve_entry_dates(
|
||||
fs_modified: Option<u64>,
|
||||
fs_created: Option<u64>,
|
||||
git_dates: Option<(u64, u64)>,
|
||||
) -> (Option<u64>, Option<u64>) {
|
||||
match git_dates {
|
||||
Some((git_modified, git_created)) => {
|
||||
let modified_at = Some(fs_modified.map_or(git_modified, |fs| fs.max(git_modified)));
|
||||
(modified_at, Some(git_created))
|
||||
}
|
||||
None => (fs_modified, fs_created),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single markdown file into a VaultEntry.
|
||||
///
|
||||
/// If `git_dates` is provided, those timestamps override filesystem metadata
|
||||
/// for `modified_at` and `created_at`. Pass `None` to use filesystem dates
|
||||
/// (appropriate for newly-saved files not yet committed, or non-git vaults).
|
||||
/// If `git_dates` is provided, `created_at` comes from git history while
|
||||
/// `modified_at` uses the newer of the latest git touch and the current
|
||||
/// filesystem modified time. Pass `None` to use filesystem dates only
|
||||
/// (appropriate for non-git vaults).
|
||||
pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<VaultEntry, String> {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
|
||||
@@ -70,10 +100,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
let word_count = count_body_words(&content);
|
||||
let outgoing_links = extract_outgoing_links(&parsed.content);
|
||||
let (fs_modified, fs_created, file_size) = read_file_metadata(path)?;
|
||||
let (modified_at, created_at) = match git_dates {
|
||||
Some((git_mod, git_create)) => (Some(git_mod), Some(git_create)),
|
||||
None => (fs_modified, fs_created),
|
||||
};
|
||||
let (modified_at, created_at) = resolve_entry_dates(fs_modified, fs_created, git_dates);
|
||||
let is_a = resolve_is_a(frontmatter.is_a);
|
||||
|
||||
// Add "Type" relationship: isA becomes a navigable link to the type document.
|
||||
@@ -89,8 +116,8 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
}
|
||||
}
|
||||
|
||||
let belongs_to = relationships.get("Belongs to").cloned().unwrap_or_default();
|
||||
let related_to = relationships.get("Related to").cloned().unwrap_or_default();
|
||||
let belongs_to = preferred_relationship_refs(&relationships, "belongs_to", "Belongs to");
|
||||
let related_to = preferred_relationship_refs(&relationships, "related_to", "Related to");
|
||||
|
||||
Ok(VaultEntry {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
@@ -141,10 +168,7 @@ pub(crate) fn parse_non_md_file(
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let (fs_modified, fs_created, file_size) = read_file_metadata(path)?;
|
||||
let (modified_at, created_at) = match git_dates {
|
||||
Some((git_mod, git_create)) => (Some(git_mod), Some(git_create)),
|
||||
None => (fs_modified, fs_created),
|
||||
};
|
||||
let (modified_at, created_at) = resolve_entry_dates(fs_modified, fs_created, git_dates);
|
||||
let file_kind = classify_file_kind(path).to_string();
|
||||
let title = extract_yml_name(path).unwrap_or_else(|| filename.clone());
|
||||
|
||||
@@ -392,7 +416,7 @@ pub fn scan_vault(
|
||||
let mut entries = Vec::new();
|
||||
scan_all_files(vault_path, git_dates, &mut entries);
|
||||
|
||||
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
entries.sort_by_key(|entry| std::cmp::Reverse(entry.modified_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
@@ -429,12 +453,24 @@ pub fn scan_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String>
|
||||
children,
|
||||
});
|
||||
}
|
||||
nodes.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
nodes.sort_by_key(|node| node.name.to_lowercase());
|
||||
nodes
|
||||
}
|
||||
Ok(build_tree(vault_path, vault_path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "frontmatter_regression_tests.rs"]
|
||||
mod frontmatter_regression_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "modified_dates_tests.rs"]
|
||||
mod modified_dates_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "relationship_key_tests.rs"]
|
||||
mod relationship_key_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "system_metadata_tests.rs"]
|
||||
mod system_metadata_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -67,8 +67,8 @@ fn test_parse_full_frontmatter_lists() {
|
||||
// As arrays of plain strings (no wikilinks), they don't appear in
|
||||
// relationships or properties — only wikilink arrays become relationships,
|
||||
// and only scalars become properties.
|
||||
assert!(entry.relationships.get("Belongs to").is_none());
|
||||
assert!(entry.relationships.get("Related to").is_none());
|
||||
assert!(!entry.relationships.contains_key("Belongs to"));
|
||||
assert!(!entry.relationships.contains_key("Related to"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -328,9 +328,9 @@ Belongs to:
|
||||
let entry = parse_md_file(&dir.path().join("some-project.md"), None).unwrap();
|
||||
|
||||
// Owner with wikilink should appear in relationships
|
||||
assert!(entry.relationships.get("Owner").is_some());
|
||||
assert!(entry.relationships.contains_key("Owner"));
|
||||
// Wikilinks don't go to properties
|
||||
assert!(entry.properties.get("Owner").is_none());
|
||||
assert!(!entry.properties.contains_key("Owner"));
|
||||
|
||||
// Belongs to is a wikilink array, should appear in relationships
|
||||
let belongs = entry.relationships.get("Belongs to").unwrap();
|
||||
@@ -387,7 +387,7 @@ fn test_parse_relationships_owner_and_notes() {
|
||||
let rels = parse_big_project_rels();
|
||||
assert_eq!(rels.get("Notes").unwrap().len(), 3);
|
||||
// Owner with wikilink should be in relationships
|
||||
assert!(rels.get("Owner").is_some());
|
||||
assert!(rels.contains_key("Owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -400,8 +400,8 @@ fn test_parse_relationships_builtin_wikilink_fields() {
|
||||
#[test]
|
||||
fn test_parse_relationships_skip_keys_excluded_from_generic() {
|
||||
let rels = parse_big_project_rels();
|
||||
assert!(rels.get("Status").is_none());
|
||||
assert!(rels.get("Is A").is_none());
|
||||
assert!(!rels.contains_key("Status"));
|
||||
assert!(!rels.contains_key("Is A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -453,17 +453,17 @@ fn parse_skip_keys_rels() -> (HashMap<String, Vec<String>>, usize) {
|
||||
#[test]
|
||||
fn test_skip_keys_identity_fields_excluded() {
|
||||
let (rels, _) = parse_skip_keys_rels();
|
||||
assert!(rels.get("Is A").is_none());
|
||||
assert!(rels.get("Aliases").is_none());
|
||||
assert!(rels.get("Status").is_none());
|
||||
assert!(!rels.contains_key("Is A"));
|
||||
assert!(!rels.contains_key("Aliases"));
|
||||
assert!(!rels.contains_key("Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skip_keys_temporal_fields_excluded() {
|
||||
let (rels, _) = parse_skip_keys_rels();
|
||||
assert!(rels.get("Cadence").is_some());
|
||||
assert!(rels.get("Created at").is_some());
|
||||
assert!(rels.get("Created time").is_some());
|
||||
assert!(rels.contains_key("Cadence"));
|
||||
assert!(rels.contains_key("Created at"));
|
||||
assert!(rels.contains_key("Created time"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -476,9 +476,9 @@ fn test_skip_keys_real_relation_included() {
|
||||
// "Real Relation" + "Type" + "Cadence" + "Created at" + "Created time"
|
||||
assert_eq!(len, 5);
|
||||
assert_eq!(rels.get("Type").unwrap(), &vec!["[[project]]".to_string()]);
|
||||
assert!(rels.get("Cadence").is_some());
|
||||
assert!(rels.get("Created at").is_some());
|
||||
assert!(rels.get("Created time").is_some());
|
||||
assert!(rels.contains_key("Cadence"));
|
||||
assert!(rels.contains_key("Created at"));
|
||||
assert!(rels.contains_key("Created time"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -633,7 +633,7 @@ fn test_type_relationship_skipped_for_type_documents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Type\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert!(entry.relationships.get("Type").is_none());
|
||||
assert!(!entry.relationships.contains_key("Type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -642,7 +642,7 @@ fn test_no_type_relationship_without_frontmatter() {
|
||||
let content = "# A Person\n\nSome content.";
|
||||
let entry = parse_test_entry(&dir, "someone.md", content);
|
||||
assert_eq!(entry.is_a, None);
|
||||
assert!(entry.relationships.get("Type").is_none());
|
||||
assert!(!entry.relationships.contains_key("Type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -693,8 +693,8 @@ fn test_type_key_not_in_relationships_as_generic() {
|
||||
// "type" key itself should not appear as a relationship (it's in SKIP_KEYS)
|
||||
// Only "Has" and the auto-generated "Type" should be relationships
|
||||
assert_eq!(entry.relationships.len(), 2);
|
||||
assert!(entry.relationships.get("Has").is_some());
|
||||
assert!(entry.relationships.get("Type").is_some());
|
||||
assert!(entry.relationships.contains_key("Has"));
|
||||
assert!(entry.relationships.contains_key("Type"));
|
||||
}
|
||||
|
||||
// --- outgoing_links tests ---
|
||||
@@ -784,7 +784,7 @@ fn test_sidebar_label_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsidebar label: My Series\n---\n# Series\n";
|
||||
let entry = parse_test_entry(&dir, "series.md", content);
|
||||
assert!(entry.relationships.get("sidebar label").is_none());
|
||||
assert!(!entry.relationships.contains_key("sidebar label"));
|
||||
}
|
||||
|
||||
// --- template field tests ---
|
||||
@@ -822,7 +822,7 @@ fn test_template_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\ntemplate: \"## Heading\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert!(entry.relationships.get("template").is_none());
|
||||
assert!(!entry.relationships.contains_key("template"));
|
||||
}
|
||||
|
||||
// --- sort field tests ---
|
||||
@@ -848,7 +848,7 @@ fn test_sort_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert!(entry.relationships.get("sort").is_none());
|
||||
assert!(!entry.relationships.contains_key("sort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -856,7 +856,7 @@ fn test_sort_not_in_properties() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert!(entry.properties.get("sort").is_none());
|
||||
assert!(!entry.properties.contains_key("sort"));
|
||||
}
|
||||
|
||||
// --- custom properties tests ---
|
||||
@@ -919,42 +919,6 @@ Priority: High
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_properties_skips_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Mentor: "[[person/alice]]"
|
||||
Company: Acme Corp
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert!(entry.properties.get("Mentor").is_none());
|
||||
assert_eq!(
|
||||
entry.properties.get("Company").and_then(|v| v.as_str()),
|
||||
Some("Acme Corp")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_properties_skips_arrays() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Tags:
|
||||
- productivity
|
||||
- writing
|
||||
Company: Acme Corp
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert!(entry.properties.get("Tags").is_none());
|
||||
assert_eq!(
|
||||
entry.properties.get("Company").and_then(|v| v.as_str()),
|
||||
Some("Acme Corp")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_lowercase_alias() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1137,7 +1101,7 @@ fn test_visible_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
|
||||
let entry = parse_test_entry(&dir, "journal.md", content);
|
||||
assert!(entry.relationships.get("visible").is_none());
|
||||
assert!(!entry.relationships.contains_key("visible"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1145,7 +1109,7 @@ fn test_visible_not_in_properties() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
|
||||
let entry = parse_test_entry(&dir, "journal.md", content);
|
||||
assert!(entry.properties.get("visible").is_none());
|
||||
assert!(!entry.properties.contains_key("visible"));
|
||||
}
|
||||
|
||||
// --- round-trip: canonical `type:` field and `Is A:` alias ---
|
||||
@@ -1176,30 +1140,6 @@ fn test_roundtrip_is_a_snake_case_alias_still_works() {
|
||||
|
||||
// --- StringOrList normalization (uniform, no per-field special cases) ---
|
||||
|
||||
#[test]
|
||||
fn test_single_element_array_owner_unwraps_to_scalar() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Responsibility\nOwner:\n - Luca\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert_eq!(
|
||||
entry.properties.get("Owner").and_then(|v| v.as_str()),
|
||||
Some("Luca")
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Responsibility".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_element_array_cadence_unwraps_to_scalar() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Procedure\nCadence:\n - Weekly\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert_eq!(
|
||||
entry.properties.get("Cadence").and_then(|v| v.as_str()),
|
||||
Some("Weekly")
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Procedure".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_element_array_status_unwraps_to_scalar() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1570,40 +1510,6 @@ Content.
|
||||
/// Regression: unquoted colon in YAML list item causes gray_matter to mis-parse.
|
||||
/// `aliases:\n - Bitcoin: Net Unrealized...` has an unquoted `:` in a list item.
|
||||
/// gray_matter may return a partial/mangled Hash instead of failing cleanly.
|
||||
#[test]
|
||||
fn test_unquoted_colon_in_list_item_breaks_parsing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Note\n_organized: true\naliases:\n - Bitcoin: Net Unrealized Profit/Loss\n - Note\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "colon-alias.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Note".to_string()),
|
||||
"type must be parsed correctly even when gray_matter fails due to unquoted colon in list item"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must be true even when gray_matter fails due to unquoted colon in list item"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: `# ` in a YAML list item is interpreted as a comment by gray_matter,
|
||||
/// truncating the frontmatter and losing subsequent fields.
|
||||
#[test]
|
||||
fn test_hash_in_list_item_treated_as_comment() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Note\n_organized: true\naliases:\n - # Writing a Good CLAUDE.md\n - Note\n---\n# Title\n";
|
||||
let entry = parse_test_entry(&dir, "hash-alias.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Note".to_string()),
|
||||
"type must be parsed even when alias starts with # (YAML comment char)"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must be true even when alias starts with # (YAML comment char)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Full reproduction: real-world Notion-imported note with unquoted colon in alias.
|
||||
#[test]
|
||||
fn test_real_world_notion_note_with_unquoted_colon() {
|
||||
|
||||
91
src-tauri/src/vault/modified_dates_tests.rs
Normal file
91
src-tauri/src/vault/modified_dates_tests.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use super::{parse_md_file, parse_non_md_file, resolve_entry_dates};
|
||||
use crate::git::GitDates;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn resolve_entry_dates_prefers_newer_filesystem_modified_time() {
|
||||
let resolved = resolve_entry_dates(Some(200), Some(50), Some((150, 25)));
|
||||
|
||||
assert_eq!(resolved, (Some(200), Some(25)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_entry_dates_keeps_newer_git_modified_time() {
|
||||
let resolved = resolve_entry_dates(Some(150), Some(50), Some((200, 25)));
|
||||
|
||||
assert_eq!(resolved, (Some(200), Some(25)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_md_file_uses_newer_filesystem_modified_time_than_git() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("note.md");
|
||||
fs::write(&path, "# Note\n\nBody\n").unwrap();
|
||||
|
||||
let (fs_modified, _, _) = super::file::read_file_metadata(&path).unwrap();
|
||||
let fs_modified = fs_modified.unwrap();
|
||||
let git_created = fs_modified.saturating_sub(600);
|
||||
let git_modified = fs_modified.saturating_sub(60);
|
||||
|
||||
let entry = parse_md_file(&path, Some((git_modified, git_created))).unwrap();
|
||||
|
||||
assert_eq!(entry.modified_at, Some(fs_modified));
|
||||
assert_eq!(entry.created_at, Some(git_created));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_non_md_file_falls_back_to_git_modified_when_filesystem_missing_newer_date() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("assets/data.txt");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, "hello").unwrap();
|
||||
|
||||
let (fs_modified, _, _) = super::file::read_file_metadata(&path).unwrap();
|
||||
let git_modified = fs_modified.unwrap().saturating_add(60);
|
||||
let git_created = git_modified.saturating_sub(600);
|
||||
|
||||
let entry = parse_non_md_file(&path, Some((git_modified, git_created))).unwrap();
|
||||
|
||||
assert_eq!(entry.modified_at, Some(git_modified));
|
||||
assert_eq!(entry.created_at, Some(git_created));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_vault_sorts_by_newer_of_git_and_filesystem_modified_time() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let older_path = dir.path().join("older-git-newer-file.md");
|
||||
let newer_git_path = dir.path().join("newer-git-older-file.md");
|
||||
|
||||
fs::write(&newer_git_path, "# Newer Git\n\nBody\n").unwrap();
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
fs::write(&older_path, "# Newer File\n\nBody\n").unwrap();
|
||||
|
||||
let (older_file_modified, _, _) = super::file::read_file_metadata(&older_path).unwrap();
|
||||
let older_file_modified = older_file_modified.unwrap();
|
||||
|
||||
let git_dates = HashMap::from([
|
||||
(
|
||||
"older-git-newer-file.md".to_string(),
|
||||
GitDates {
|
||||
created_at: older_file_modified.saturating_sub(600),
|
||||
modified_at: older_file_modified.saturating_sub(120),
|
||||
},
|
||||
),
|
||||
(
|
||||
"newer-git-older-file.md".to_string(),
|
||||
GitDates {
|
||||
created_at: older_file_modified.saturating_sub(700),
|
||||
modified_at: older_file_modified.saturating_sub(30),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let entries = super::scan_vault(dir.path(), &git_dates).unwrap();
|
||||
let titles: Vec<_> = entries.iter().map(|entry| entry.title.as_str()).collect();
|
||||
|
||||
assert_eq!(titles, vec!["Newer File", "Newer Git"]);
|
||||
}
|
||||
63
src-tauri/src/vault/relationship_key_tests.rs
Normal file
63
src-tauri/src/vault/relationship_key_tests.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_file(dir: &Path, name: &str, content: &str) {
|
||||
let file_path = dir.join(name);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
let mut file = fs::File::create(file_path).unwrap();
|
||||
file.write_all(content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
fn parse_test_entry(content: &str) -> VaultEntry {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "relationship-note.md", content);
|
||||
parse_md_file(&dir.path().join("relationship-note.md"), None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_snake_case_relationship_keys_for_convenience_fields() {
|
||||
let entry = parse_test_entry(
|
||||
"---\n\
|
||||
belongs_to:\n\
|
||||
- \"[[canonical-parent]]\"\n\
|
||||
\"Belongs to\":\n\
|
||||
- \"[[legacy-parent]]\"\n\
|
||||
related_to:\n\
|
||||
- \"[[canonical-related]]\"\n\
|
||||
\"Related to\":\n\
|
||||
- \"[[legacy-related]]\"\n\
|
||||
---\n\
|
||||
# Relationship Note\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.belongs_to, vec!["[[canonical-parent]]"]);
|
||||
assert_eq!(entry.related_to, vec!["[[canonical-related]]"]);
|
||||
assert_eq!(
|
||||
entry.relationships.get("belongs_to"),
|
||||
Some(&vec!["[[canonical-parent]]".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
entry.relationships.get("Belongs to"),
|
||||
Some(&vec!["[[legacy-parent]]".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_legacy_relationship_keys_when_snake_case_is_absent() {
|
||||
let entry = parse_test_entry(
|
||||
"---\n\
|
||||
Belongs to:\n\
|
||||
- \"[[legacy-parent]]\"\n\
|
||||
Related to:\n\
|
||||
- \"[[legacy-related]]\"\n\
|
||||
---\n\
|
||||
# Relationship Note\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.belongs_to, vec!["[[legacy-parent]]"]);
|
||||
assert_eq!(entry.related_to, vec!["[[legacy-related]]"]);
|
||||
}
|
||||
73
src-tauri/src/vault/system_metadata_tests.rs
Normal file
73
src-tauri/src/vault/system_metadata_tests.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_file(dir: &Path, name: &str, content: &str) {
|
||||
let file_path = dir.join(name);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
let mut file = fs::File::create(file_path).unwrap();
|
||||
file.write_all(content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
fn parse_test_entry(dir: &TempDir, name: &str, content: &str) -> VaultEntry {
|
||||
create_test_file(dir.path(), name, content);
|
||||
parse_md_file(&dir.path().join(name), None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_canonical_system_metadata_keys() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"project.md",
|
||||
"---\ntype: Type\n_icon: rocket\n_order: 4\n_sidebar_label: Projects\n_sort: title:asc\n---\n# Project\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.icon.as_deref(), Some("rocket"));
|
||||
assert_eq!(entry.order, Some(4));
|
||||
assert_eq!(entry.sidebar_label.as_deref(), Some("Projects"));
|
||||
assert_eq!(entry.sort.as_deref(), Some("title:asc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_legacy_system_metadata_keys_without_property_leaks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"project.md",
|
||||
"---\ntype: Type\nicon: rocket\norder: 4\nsidebar label: Projects\nsort: title:asc\n---\n# Project\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.icon.as_deref(), Some("rocket"));
|
||||
assert_eq!(entry.order, Some(4));
|
||||
assert_eq!(entry.sidebar_label.as_deref(), Some("Projects"));
|
||||
assert_eq!(entry.sort.as_deref(), Some("title:asc"));
|
||||
assert!(!entry.properties.contains_key("icon"));
|
||||
assert!(!entry.properties.contains_key("order"));
|
||||
assert!(!entry.properties.contains_key("sidebar label"));
|
||||
assert!(!entry.properties.contains_key("sort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unknown_underscore_keys_in_properties_and_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"note.md",
|
||||
"---\ntype: Note\n_internal: secret\n_hidden_link: \"[[secret]]\"\nOwner: Luca\n---\n# Note\n",
|
||||
);
|
||||
|
||||
assert!(!entry.properties.contains_key("_internal"));
|
||||
assert!(!entry.relationships.contains_key("_hidden_link"));
|
||||
assert_eq!(
|
||||
entry
|
||||
.properties
|
||||
.get("Owner")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("Luca")
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"title": "Tolaria",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 1200,
|
||||
"minWidth": 480,
|
||||
"minHeight": 400,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
@@ -54,7 +54,7 @@
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://refactoringhq.github.io/tolaria/latest.json"
|
||||
"https://refactoringhq.github.io/tolaria/stable/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE4NkQ5MDI3REVCRkFGNUMKUldSY3I3L2VKNUJ0cU5JRlRZZlp3NGhnU3ZwbkVKeGVvREpmb2sxRVJndHFpVFZPNlArbEE5R1IK"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,20 @@ vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/window', async () => {
|
||||
const actual = await vi.importActual<typeof import('@tauri-apps/api/window')>('@tauri-apps/api/window')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getCurrentWindow: () => ({
|
||||
innerSize: vi.fn(async () => ({ toLogical: () => ({ width: 1400, height: 900 }) })),
|
||||
scaleFactor: vi.fn(async () => 1),
|
||||
setMinSize: vi.fn(async () => {}),
|
||||
setSize: vi.fn(async () => {}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock mock-tauri module
|
||||
const mockEntries = [
|
||||
{
|
||||
@@ -62,6 +76,7 @@ const mockAllContent: Record<string, string> = {
|
||||
}
|
||||
|
||||
const mockCommandResults: Record<string, unknown> = {
|
||||
load_vault_list: { vaults: [], active_vault: null, hidden_defaults: [] },
|
||||
list_vault: mockEntries,
|
||||
list_vault_folders: [],
|
||||
list_views: [],
|
||||
@@ -69,7 +84,7 @@ const mockCommandResults: Record<string, unknown> = {
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
get_file_history: [],
|
||||
get_settings: { github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null },
|
||||
get_settings: { auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null },
|
||||
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
|
||||
save_settings: null,
|
||||
check_vault_exists: true,
|
||||
@@ -94,11 +109,16 @@ vi.mock('./utils/ai-chat', () => ({
|
||||
|
||||
// Mock BlockNote components (they need DOM APIs not available in jsdom)
|
||||
vi.mock('@blocknote/core', () => ({
|
||||
BlockNoteSchema: { create: () => ({}) },
|
||||
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
|
||||
createCodeBlockSpec: vi.fn(() => ({})),
|
||||
defaultInlineContentSpecs: {},
|
||||
filterSuggestionItems: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/code-block', () => ({
|
||||
codeBlockOptions: {},
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/core/extensions', () => ({
|
||||
filterSuggestionItems: vi.fn(() => []),
|
||||
}))
|
||||
@@ -123,16 +143,13 @@ vi.mock('@blocknote/mantine/style.css', () => ({}))
|
||||
|
||||
import App from './App'
|
||||
|
||||
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Reset view mode and onboarding state between tests
|
||||
localStorage.removeItem('tolaria-view-mode')
|
||||
localStorage.removeItem('tolaria-view-mode')
|
||||
localStorage.removeItem('laputa-view-mode')
|
||||
localStorage.removeItem('tolaria_welcome_dismissed')
|
||||
localStorage.removeItem('laputa_welcome_dismissed')
|
||||
localStorage.clear()
|
||||
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
|
||||
})
|
||||
|
||||
it('renders the four-panel layout', async () => {
|
||||
@@ -270,4 +287,35 @@ describe('App', () => {
|
||||
expect(document.querySelector('.app__note-list')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('updates the main-window size constraints when the view mode changes', async () => {
|
||||
const { invoke } = await import('@tauri-apps/api/core') as { invoke: ReturnType<typeof vi.fn> }
|
||||
|
||||
render(<App />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
invoke.mockClear()
|
||||
|
||||
fireEvent.keyDown(window, { key: '1', metaKey: true })
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('update_current_window_min_size', {
|
||||
minWidth: 480,
|
||||
minHeight: 400,
|
||||
growToFit: true,
|
||||
})
|
||||
})
|
||||
|
||||
invoke.mockClear()
|
||||
|
||||
fireEvent.keyDown(window, { key: '3', metaKey: true })
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('update_current_window_min_size', {
|
||||
minWidth: 880,
|
||||
minHeight: 400,
|
||||
growToFit: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
406
src/App.tsx
406
src/App.tsx
@@ -14,23 +14,28 @@ import { CommitDialog } from './components/CommitDialog'
|
||||
import { PulseView } from './components/PulseView'
|
||||
import { StatusBar } from './components/StatusBar'
|
||||
import { SettingsPanel } from './components/SettingsPanel'
|
||||
import { GitHubVaultModal } from './components/GitHubVaultModal'
|
||||
import { CloneVaultModal } from './components/CloneVaultModal'
|
||||
import { WelcomeScreen } from './components/WelcomeScreen'
|
||||
import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt'
|
||||
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
|
||||
import { FeedbackDialog } from './components/FeedbackDialog'
|
||||
import { useTelemetry } from './hooks/useTelemetry'
|
||||
import { useMcpStatus } from './hooks/useMcpStatus'
|
||||
import { useClaudeCodeStatus } from './hooks/useClaudeCodeStatus'
|
||||
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
|
||||
import { useAiAgentsStatus } from './hooks/useAiAgentsStatus'
|
||||
import { useVaultAiGuidanceStatus } from './hooks/useVaultAiGuidanceStatus'
|
||||
import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteActions } from './hooks/useNoteActions'
|
||||
import { useCommitFlow } from './hooks/useCommitFlow'
|
||||
import { useViewMode } from './hooks/useViewMode'
|
||||
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
|
||||
import { useViewMode, type ViewMode } from './hooks/useViewMode'
|
||||
import { useEntryActions } from './hooks/useEntryActions'
|
||||
import { useAppCommands } from './hooks/useAppCommands'
|
||||
import { generateCommitMessage } from './utils/commitMessage'
|
||||
import { useDialogs } from './hooks/useDialogs'
|
||||
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
|
||||
import { GETTING_STARTED_LABEL, useVaultSwitcher } from './hooks/useVaultSwitcher'
|
||||
import { useGitHistory } from './hooks/useGitHistory'
|
||||
import { useUpdater, restartApp } from './hooks/useUpdater'
|
||||
import { useAutoSync } from './hooks/useAutoSync'
|
||||
@@ -39,7 +44,14 @@ import { useZoom } from './hooks/useZoom'
|
||||
import { useVaultConfig } from './hooks/useVaultConfig'
|
||||
import { useBuildNumber } from './hooks/useBuildNumber'
|
||||
import { useOnboarding } from './hooks/useOnboarding'
|
||||
import { useGettingStartedClone } from './hooks/useGettingStartedClone'
|
||||
import { useNetworkStatus } from './hooks/useNetworkStatus'
|
||||
import { useAppNavigation } from './hooks/useAppNavigation'
|
||||
import {
|
||||
applyMainWindowSizeConstraints,
|
||||
getMainWindowMinWidth,
|
||||
useMainWindowSizeConstraints,
|
||||
} from './hooks/useMainWindowSizeConstraints'
|
||||
import { useAiActivity } from './hooks/useAiActivity'
|
||||
import { useBulkActions } from './hooks/useBulkActions'
|
||||
import { useDeleteActions } from './hooks/useDeleteActions'
|
||||
@@ -47,8 +59,10 @@ import { useLayoutPanels } from './hooks/useLayoutPanels'
|
||||
import { useConflictFlow } from './hooks/useConflictFlow'
|
||||
import { useAppSave } from './hooks/useAppSave'
|
||||
import { useVaultBridge } from './hooks/useVaultBridge'
|
||||
import type { CommitDiffRequest } from './hooks/useDiffMode'
|
||||
import { ConflictResolverModal } from './components/ConflictResolverModal'
|
||||
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
|
||||
import { DeleteProgressNotice } from './components/DeleteProgressNotice'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
@@ -56,14 +70,19 @@ import type { SidebarSelection, InboxPeriod, VaultEntry } from './types'
|
||||
import type { NoteListItem } from './utils/ai-context'
|
||||
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
|
||||
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, findNoteWindowEntry, type NoteWindowParams } from './utils/windowMode'
|
||||
import { GitRequiredModal } from './components/GitRequiredModal'
|
||||
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
|
||||
import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents'
|
||||
import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands'
|
||||
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import {
|
||||
buildVaultAiGuidanceRefreshKey,
|
||||
} from './lib/vaultAiGuidance'
|
||||
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
|
||||
import { hasNoteIconValue } from './utils/noteIcon'
|
||||
import { filenameStemToTitle } from './utils/noteTitle'
|
||||
import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge'
|
||||
import {
|
||||
INBOX_SELECTION,
|
||||
@@ -83,6 +102,70 @@ declare global {
|
||||
|
||||
const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION
|
||||
|
||||
async function resolveNoteWindowEntry(
|
||||
noteWindowParams: NoteWindowParams,
|
||||
entries: VaultEntry[],
|
||||
): Promise<VaultEntry | undefined> {
|
||||
const fallbackEntry = () =>
|
||||
findNoteWindowEntry(entries, noteWindowParams)
|
||||
|
||||
if (!isTauri()) {
|
||||
return fallbackEntry()
|
||||
}
|
||||
|
||||
for (const path of getNoteWindowPathCandidates(noteWindowParams)) {
|
||||
try {
|
||||
return await invoke<VaultEntry>('reload_vault_entry', { path })
|
||||
} catch {
|
||||
// Try the next normalized candidate before falling back to the scanned entries.
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackEntry()
|
||||
}
|
||||
|
||||
function createPulseDeletedNoteEntry(fullPath: string, relativePath: string): DeletedNoteEntry {
|
||||
const filename = relativePath.split('/').pop() ?? relativePath
|
||||
return {
|
||||
path: fullPath,
|
||||
filename,
|
||||
title: filenameStemToTitle(filename),
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: null,
|
||||
createdAt: null,
|
||||
fileSize: 0,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
__deletedNotePreview: true,
|
||||
__deletedRelativePath: relativePath,
|
||||
__changeAddedLines: null,
|
||||
__changeDeletedLines: null,
|
||||
__changeBinary: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
|
||||
function App() {
|
||||
const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, [])
|
||||
@@ -96,12 +179,14 @@ function App() {
|
||||
const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined)
|
||||
const { setInspectorCollapsed } = layout
|
||||
const visibleNotesRef = useRef<VaultEntry[]>([])
|
||||
const multiSelectionCommandRef = useRef<NoteListMultiSelectionCommands | null>(null)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const dialogs = useDialogs()
|
||||
const { showAIChat, toggleAIChat } = dialogs
|
||||
const [showFeedback, setShowFeedback] = useState(false)
|
||||
const openFeedback = useCallback(() => setShowFeedback(true), [])
|
||||
const closeFeedback = useCallback(() => setShowFeedback(false), [])
|
||||
const networkStatus = useNetworkStatus()
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenAiChat = () => {
|
||||
@@ -119,10 +204,24 @@ function App() {
|
||||
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
const { handleVaultCloned } = vaultSwitcher
|
||||
|
||||
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
|
||||
const handleGettingStartedVaultReady = useCallback((vaultPath: string, label: string) => {
|
||||
handleVaultCloned(vaultPath, label)
|
||||
setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`)
|
||||
}, [handleVaultCloned])
|
||||
const cloneGettingStartedVault = useGettingStartedClone({
|
||||
onError: (message) => setToastMessage(message),
|
||||
onSuccess: handleGettingStartedVaultReady,
|
||||
})
|
||||
const onboarding = useOnboarding(vaultSwitcher.vaultPath, (vaultPath) => {
|
||||
handleGettingStartedVaultReady(vaultPath, GETTING_STARTED_LABEL)
|
||||
})
|
||||
const aiAgentsStatus = useAiAgentsStatus()
|
||||
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
|
||||
|
||||
// When onboarding resolves to a different vault path, update the switcher
|
||||
// The active vault path can temporarily come from onboarding before the
|
||||
// persisted vault switcher catches up to the newly cloned starter vault.
|
||||
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
|
||||
// Git repo check: 'checking' | 'required' | 'ready'
|
||||
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
|
||||
@@ -143,6 +242,13 @@ function App() {
|
||||
}, [resolvedPath])
|
||||
|
||||
const vault = useVaultLoader(resolvedPath)
|
||||
const {
|
||||
status: vaultAiGuidanceStatus,
|
||||
refresh: refreshVaultAiGuidance,
|
||||
} = useVaultAiGuidanceStatus(
|
||||
resolvedPath,
|
||||
buildVaultAiGuidanceRefreshKey(vault.entries),
|
||||
)
|
||||
const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath)
|
||||
const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization)
|
||||
const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization)
|
||||
@@ -161,6 +267,12 @@ function App() {
|
||||
})
|
||||
}, [updateConfig, vaultConfig.inbox?.noteListProperties])
|
||||
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
||||
const aiAgentPreferences = useAiAgentPreferences({
|
||||
settings,
|
||||
saveSettings,
|
||||
aiAgentsStatus,
|
||||
onToast: setToastMessage,
|
||||
})
|
||||
useTelemetry(settings, settingsLoaded)
|
||||
|
||||
const vaultOpenedRef = useRef('')
|
||||
@@ -171,7 +283,6 @@ function App() {
|
||||
}
|
||||
}, [vault.entries.length, gitRepoState, resolvedPath])
|
||||
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
const { status: claudeCodeStatus, version: claudeCodeVersion } = useClaudeCodeStatus()
|
||||
|
||||
const autoSync = useAutoSync({
|
||||
vaultPath: resolvedPath,
|
||||
@@ -183,6 +294,7 @@ function App() {
|
||||
},
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
|
||||
|
||||
// Detect external file renames on window focus
|
||||
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
|
||||
@@ -223,18 +335,54 @@ function App() {
|
||||
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
|
||||
})
|
||||
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry, onFrontmatterPersisted: vault.loadModifiedFiles })
|
||||
const notes = useNoteActions({
|
||||
addEntry: vault.addEntry,
|
||||
removeEntry: vault.removeEntry,
|
||||
entries: vault.entries,
|
||||
flushBeforePathRename: (path) => appSave.flushBeforeAction(path),
|
||||
reloadVault: vault.reloadVault,
|
||||
setToastMessage,
|
||||
updateEntry: vault.updateEntry,
|
||||
vaultPath: resolvedPath,
|
||||
addPendingSave: vault.addPendingSave,
|
||||
removePendingSave: vault.removePendingSave,
|
||||
trackUnsaved: vault.trackUnsaved,
|
||||
clearUnsaved: vault.clearUnsaved,
|
||||
unsavedPaths: vault.unsavedPaths,
|
||||
markContentPending: (path, content) => appSave.contentChangeRef.current(path, content),
|
||||
onNewNotePersisted: vault.loadModifiedFiles,
|
||||
replaceEntry: vault.replaceEntry,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath),
|
||||
})
|
||||
const { handleSelectNote, openTabWithContent } = notes
|
||||
const pulseCommitDiffRequestIdRef = useRef(0)
|
||||
const [pulseCommitDiffRequest, setPulseCommitDiffRequest] = useState<CommitDiffRequest | null>(null)
|
||||
|
||||
// Note window: auto-open the note from URL params once vault entries load
|
||||
const noteWindowOpenedRef = useRef(false)
|
||||
const noteWindowMissingPathRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!noteWindowParams || noteWindowOpenedRef.current || vault.entries.length === 0) return
|
||||
const entry = vault.entries.find(e => e.path === noteWindowParams.notePath)
|
||||
if (entry) {
|
||||
noteWindowOpenedRef.current = true
|
||||
notes.handleSelectNote(entry)
|
||||
if (!noteWindowParams || noteWindowOpenedRef.current) return
|
||||
let cancelled = false
|
||||
|
||||
void resolveNoteWindowEntry(noteWindowParams, vault.entries).then((entry) => {
|
||||
if (cancelled || noteWindowOpenedRef.current) return
|
||||
if (entry) {
|
||||
noteWindowOpenedRef.current = true
|
||||
noteWindowMissingPathRef.current = null
|
||||
void handleSelectNote(entry)
|
||||
return
|
||||
}
|
||||
if (noteWindowMissingPathRef.current === noteWindowParams.notePath) return
|
||||
noteWindowMissingPathRef.current = noteWindowParams.notePath
|
||||
setToastMessage(`Could not open "${noteWindowParams.noteTitle}" in this window`)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [vault.entries]) // eslint-disable-line react-hooks/exhaustive-deps -- run when entries load, params are stable
|
||||
}, [handleSelectNote, noteWindowParams, setToastMessage, vault.entries])
|
||||
|
||||
// Note window: update window title when active note changes
|
||||
useEffect(() => {
|
||||
@@ -270,6 +418,41 @@ function App() {
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
})
|
||||
|
||||
const queuePulseCommitDiff = useCallback((path: string, commitHash: string) => {
|
||||
pulseCommitDiffRequestIdRef.current += 1
|
||||
setPulseCommitDiffRequest({
|
||||
requestId: pulseCommitDiffRequestIdRef.current,
|
||||
path,
|
||||
commitHash,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePulseCommitDiffHandled = useCallback((requestId: number) => {
|
||||
setPulseCommitDiffRequest((current) =>
|
||||
current?.requestId === requestId ? null : current,
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handlePulseOpenNote = useCallback((relativePath: string, commitHash?: string) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
const entry = entriesByPath.get(fullPath) ?? entriesByPath.get(relativePath)
|
||||
|
||||
if (commitHash) {
|
||||
const targetPath = entry?.path ?? fullPath
|
||||
queuePulseCommitDiff(targetPath, commitHash)
|
||||
if (entry) {
|
||||
void handleSelectNote(entry)
|
||||
} else {
|
||||
openTabWithContent(createPulseDeletedNoteEntry(fullPath, relativePath), 'Content not available')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (entry) {
|
||||
void handleSelectNote(entry)
|
||||
}
|
||||
}, [entriesByPath, resolvedPath, queuePulseCommitDiff, handleSelectNote, openTabWithContent])
|
||||
|
||||
const vaultBridge = useVaultBridge({
|
||||
entriesByPath, resolvedPath,
|
||||
reloadVault: vault.reloadVault,
|
||||
@@ -295,17 +478,11 @@ function App() {
|
||||
loadModifiedFiles: vault.loadModifiedFiles, reloadViews: vault.reloadViews,
|
||||
clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths,
|
||||
tabs: notes.tabs, activeTabPath: notes.activeTabPath,
|
||||
handleRenameNote: notes.handleRenameNote,
|
||||
handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename,
|
||||
replaceEntry: vault.replaceEntry, resolvedPath,
|
||||
initialH1AutoRenameEnabled: settings.initial_h1_auto_rename_enabled !== false,
|
||||
})
|
||||
|
||||
const handleFilenameRename = useCallback((path: string, newFilenameStem: string) => {
|
||||
appSave.savePendingForPath(path)
|
||||
.then(() => notes.handleRenameFilename(path, newFilenameStem, resolvedPath, vault.replaceEntry))
|
||||
.then(vault.loadModifiedFiles)
|
||||
.catch((err) => console.error('Filename rename failed:', err))
|
||||
}, [appSave, notes, resolvedPath, vault])
|
||||
|
||||
const aiActivity = useAiActivity({
|
||||
onOpenNote: vaultBridge.openNoteByPath,
|
||||
onOpenTab: vaultBridge.openNoteByPath,
|
||||
@@ -334,9 +511,23 @@ function App() {
|
||||
})
|
||||
}, [setInspectorCollapsed])
|
||||
|
||||
const handleCustomizeInboxColumns = useCallback(() => {
|
||||
openNoteListPropertiesPicker('inbox')
|
||||
}, [])
|
||||
const handleCustomizeNoteListColumns = useCallback(() => {
|
||||
if (effectiveSelection.kind !== 'filter') return
|
||||
if (effectiveSelection.filter === 'all') {
|
||||
openNoteListPropertiesPicker('all')
|
||||
return
|
||||
}
|
||||
if (effectiveSelection.filter === 'inbox') {
|
||||
openNoteListPropertiesPicker('inbox')
|
||||
}
|
||||
}, [effectiveSelection])
|
||||
|
||||
const handleUpdateAllNotesNoteListProperties = useCallback((value: string[] | null) => {
|
||||
updateConfig('allNotes', {
|
||||
...(vaultConfig.allNotes ?? { noteListProperties: null }),
|
||||
noteListProperties: value && value.length > 0 ? value : null,
|
||||
})
|
||||
}, [updateConfig, vaultConfig.allNotes])
|
||||
|
||||
const handleUpdateInboxNoteListProperties = useCallback((value: string[] | null) => {
|
||||
updateConfig('inbox', {
|
||||
@@ -416,7 +607,14 @@ function App() {
|
||||
}
|
||||
}, [vault, notes, setToastMessage])
|
||||
|
||||
const commitFlow = useCommitFlow({ savePending: appSave.savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
|
||||
const commitFlow = useCommitFlow({
|
||||
savePending: appSave.savePending,
|
||||
loadModifiedFiles: vault.loadModifiedFiles,
|
||||
resolveRemoteStatus: gitRemoteStatus.refreshRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected: autoSync.handlePushRejected,
|
||||
vaultPath: resolvedPath,
|
||||
})
|
||||
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
|
||||
|
||||
const entryActions = useEntryActions({
|
||||
@@ -430,6 +628,9 @@ function App() {
|
||||
const deleteActions = useDeleteActions({
|
||||
onDeselectNote: (path: string) => { if (notes.activeTabPath === path) notes.closeAllTabs() },
|
||||
removeEntry: vault.removeEntry,
|
||||
removeEntries: vault.removeEntries,
|
||||
refreshModifiedFiles: vault.loadModifiedFiles,
|
||||
reloadVault: vault.reloadVault,
|
||||
setToastMessage,
|
||||
})
|
||||
|
||||
@@ -487,7 +688,7 @@ function App() {
|
||||
return [...builtIn, ...Array.from(customFields).sort()]
|
||||
}, [vault.entries])
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
const bulkActions = useBulkActions(entryActions, vault.entries, setToastMessage)
|
||||
|
||||
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
|
||||
const rawToggleRef = useRef<() => void>(() => {})
|
||||
@@ -498,7 +699,46 @@ function App() {
|
||||
const zoom = useZoom()
|
||||
const buildNumber = useBuildNumber()
|
||||
|
||||
const { status: updateStatus, actions: updateActions } = useUpdater()
|
||||
const updateMainWindowConstraints = useCallback((
|
||||
nextSidebarVisible: boolean,
|
||||
nextNoteListVisible: boolean,
|
||||
nextInspectorCollapsed: boolean = layout.inspectorCollapsed,
|
||||
) => {
|
||||
if (noteWindowParams) return
|
||||
|
||||
const minWidth = getMainWindowMinWidth({
|
||||
sidebarVisible: nextSidebarVisible,
|
||||
noteListVisible: nextNoteListVisible,
|
||||
inspectorCollapsed: nextInspectorCollapsed,
|
||||
})
|
||||
|
||||
void applyMainWindowSizeConstraints(minWidth).catch(() => {})
|
||||
}, [layout.inspectorCollapsed, noteWindowParams])
|
||||
|
||||
const handleSetViewMode = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode)
|
||||
updateMainWindowConstraints(mode === 'all', mode !== 'editor-only')
|
||||
}, [setViewMode, updateMainWindowConstraints])
|
||||
|
||||
const handleToggleInspector = useCallback(() => {
|
||||
const nextInspectorCollapsed = !layout.inspectorCollapsed
|
||||
layout.setInspectorCollapsed(nextInspectorCollapsed)
|
||||
updateMainWindowConstraints(sidebarVisible, noteListVisible, nextInspectorCollapsed)
|
||||
}, [
|
||||
layout,
|
||||
noteListVisible,
|
||||
sidebarVisible,
|
||||
updateMainWindowConstraints,
|
||||
])
|
||||
|
||||
useMainWindowSizeConstraints({
|
||||
enabled: !noteWindowParams,
|
||||
sidebarVisible,
|
||||
noteListVisible,
|
||||
inspectorCollapsed: layout.inspectorCollapsed,
|
||||
})
|
||||
|
||||
const { status: updateStatus, actions: updateActions } = useUpdater(settings.release_channel)
|
||||
|
||||
const handleCheckForUpdates = useCallback(async () => {
|
||||
if (updateStatus.state === 'downloading') {
|
||||
@@ -523,11 +763,25 @@ function App() {
|
||||
const tauriInvoke = isTauri() ? invoke : mockInvoke
|
||||
const msg = await tauriInvoke<string>('repair_vault', { vaultPath: resolvedPath })
|
||||
await vault.reloadVault()
|
||||
await refreshVaultAiGuidance()
|
||||
setToastMessage(msg)
|
||||
} catch (err) {
|
||||
setToastMessage(`Failed to repair vault: ${err}`)
|
||||
}
|
||||
}, [resolvedPath, vault, setToastMessage])
|
||||
}, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage])
|
||||
|
||||
const restoreVaultAiGuidance = useCallback(async (successToast: string | null = 'Tolaria AI guidance restored') => {
|
||||
if (!resolvedPath) return
|
||||
try {
|
||||
const tauriInvoke = isTauri() ? invoke : mockInvoke
|
||||
await tauriInvoke('restore_vault_ai_guidance', { vaultPath: resolvedPath })
|
||||
await vault.reloadVault()
|
||||
await refreshVaultAiGuidance()
|
||||
if (successToast) setToastMessage(successToast)
|
||||
} catch (err) {
|
||||
setToastMessage(`Failed to restore Tolaria AI guidance: ${err}`)
|
||||
}
|
||||
}, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage])
|
||||
|
||||
const activeDeletedFile = useMemo(() => {
|
||||
const activeTabPath = notes.activeTabPath
|
||||
@@ -542,13 +796,13 @@ function App() {
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
entries: vault.entries,
|
||||
visibleNotesRef,
|
||||
multiSelectionCommandRef,
|
||||
modifiedCount: vault.modifiedFiles.length,
|
||||
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
||||
selection: effectiveSelection,
|
||||
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
|
||||
onSearch: dialogs.openSearch,
|
||||
onCreateNote: notes.handleCreateNoteImmediate,
|
||||
onOpenDailyNote: notes.handleOpenDailyNote,
|
||||
onCreateNoteOfType: notes.handleCreateNoteImmediate,
|
||||
onSave: appSave.handleSave,
|
||||
onOpenSettings: dialogs.openSettings,
|
||||
@@ -558,8 +812,8 @@ function App() {
|
||||
onCommitPush: commitFlow.openCommitDialog,
|
||||
onPull: autoSync.triggerSync,
|
||||
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
|
||||
onSetViewMode: setViewMode,
|
||||
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
|
||||
onSetViewMode: handleSetViewMode,
|
||||
onToggleInspector: handleToggleInspector,
|
||||
onToggleDiff: () => diffToggleRef.current(),
|
||||
onToggleRawEditor: activeDeletedFile ? undefined : () => rawToggleRef.current(),
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
@@ -575,13 +829,19 @@ function App() {
|
||||
onToggleAIChat: dialogs.toggleAIChat,
|
||||
onCheckForUpdates: handleCheckForUpdates,
|
||||
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
|
||||
onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted,
|
||||
onRestoreGettingStarted: cloneGettingStartedVault,
|
||||
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
|
||||
vaultCount: vaultSwitcher.allVaults.length,
|
||||
mcpStatus,
|
||||
onInstallMcp: installMcp,
|
||||
claudeCodeStatus: claudeCodeStatus ?? undefined,
|
||||
claudeCodeVersion: claudeCodeVersion ?? undefined,
|
||||
onOpenAiAgents: dialogs.openSettings,
|
||||
aiAgentsStatus,
|
||||
vaultAiGuidanceStatus,
|
||||
onRestoreVaultAiGuidance: () => { void restoreVaultAiGuidance() },
|
||||
selectedAiAgent: aiAgentPreferences.defaultAiAgent,
|
||||
onSetDefaultAiAgent: aiAgentPreferences.setDefaultAiAgent,
|
||||
onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent,
|
||||
selectedAiAgentLabel: aiAgentPreferences.defaultAiAgentLabel,
|
||||
onReloadVault: vault.reloadVault,
|
||||
onRepairVault: handleRepairVault,
|
||||
onSetNoteIcon: handleSetNoteIconCommand,
|
||||
@@ -595,8 +855,9 @@ function App() {
|
||||
onOpenInNewWindow: handleOpenInNewWindow,
|
||||
onToggleFavorite: entryActions.handleToggleFavorite,
|
||||
onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined,
|
||||
onCustomizeInboxColumns: handleCustomizeInboxColumns,
|
||||
canCustomizeInboxColumns: explicitOrganizationEnabled && effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox',
|
||||
onCustomizeNoteListColumns: handleCustomizeNoteListColumns,
|
||||
canCustomizeNoteListColumns: effectiveSelection.kind === 'filter'
|
||||
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox')),
|
||||
onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
|
||||
canRestoreDeletedNote: !!activeDeletedFile,
|
||||
})
|
||||
@@ -621,7 +882,7 @@ function App() {
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
|
||||
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing')) {
|
||||
return <WelcomeView onboarding={onboarding} />
|
||||
return <WelcomeView onboarding={onboarding} isOffline={networkStatus.isOffline} />
|
||||
}
|
||||
|
||||
// Show loading spinner while checking vault (skip for note windows)
|
||||
@@ -629,6 +890,18 @@ function App() {
|
||||
return <LoadingView />
|
||||
}
|
||||
|
||||
if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) {
|
||||
return (
|
||||
<>
|
||||
<AiAgentsOnboardingView
|
||||
statuses={aiAgentsStatus}
|
||||
onContinue={aiAgentsOnboarding.dismissPrompt}
|
||||
/>
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Show git-required modal when vault has no git repo (skip for note windows)
|
||||
if (!noteWindowParams && gitRepoState === 'required') {
|
||||
return (
|
||||
@@ -676,9 +949,9 @@ function App() {
|
||||
<>
|
||||
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
||||
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} />
|
||||
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -692,11 +965,15 @@ function App() {
|
||||
onNavigateWikilink={notes.handleNavigateWikilink}
|
||||
onLoadDiff={vault.loadDiff}
|
||||
onLoadDiffAtCommit={vault.loadDiffAtCommit}
|
||||
pendingCommitDiffRequest={pulseCommitDiffRequest}
|
||||
onPendingCommitDiffHandled={handlePulseCommitDiffHandled}
|
||||
getNoteStatus={vault.getNoteStatus}
|
||||
onCreateNote={notes.handleCreateNoteImmediate}
|
||||
inspectorCollapsed={layout.inspectorCollapsed}
|
||||
onToggleInspector={() => layout.setInspectorCollapsed((c) => !c)}
|
||||
onToggleInspector={handleToggleInspector}
|
||||
inspectorWidth={layout.inspectorWidth}
|
||||
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
|
||||
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
|
||||
onInspectorResize={layout.handleInspectorResize}
|
||||
inspectorEntry={activeTab?.entry ?? null}
|
||||
inspectorContent={activeTab?.content ?? null}
|
||||
@@ -718,7 +995,7 @@ function App() {
|
||||
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
|
||||
onContentChange={appSave.handleContentChange}
|
||||
onSave={appSave.handleSave}
|
||||
onRenameFilename={activeDeletedFile ? undefined : handleFilenameRename}
|
||||
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
|
||||
rawToggleRef={rawToggleRef}
|
||||
diffToggleRef={diffToggleRef}
|
||||
canGoBack={canGoBack}
|
||||
@@ -737,20 +1014,29 @@ function App() {
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette
|
||||
open={dialogs.showCommandPalette}
|
||||
commands={commands}
|
||||
entries={vault.entries}
|
||||
claudeCodeReady={claudeCodeStatus === 'installed'}
|
||||
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
|
||||
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
|
||||
onClose={dialogs.closeCommandPalette}
|
||||
/>
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
|
||||
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
|
||||
<CommitDialog
|
||||
open={commitFlow.showCommitDialog}
|
||||
modifiedCount={vault.modifiedFiles.length}
|
||||
commitMode={commitFlow.commitMode}
|
||||
suggestedMessage={suggestedCommitMessage}
|
||||
onCommit={commitFlow.handleCommitPush}
|
||||
onClose={commitFlow.closeCommitDialog}
|
||||
/>
|
||||
<ConflictResolverModal
|
||||
open={dialogs.showConflictResolver}
|
||||
fileStates={conflictResolver.fileStates}
|
||||
@@ -762,16 +1048,9 @@ function App() {
|
||||
onCommit={conflictResolver.commitResolution}
|
||||
onClose={conflictFlow.handleCloseConflictResolver}
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
|
||||
<GitHubVaultModal
|
||||
open={dialogs.showGitHubVault}
|
||||
githubToken={settings.github_token}
|
||||
onClose={dialogs.closeGitHubVault}
|
||||
onVaultCloned={vaultSwitcher.handleVaultCloned}
|
||||
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
|
||||
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
|
||||
/>
|
||||
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
|
||||
{deleteActions.confirmDelete && (
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
@@ -789,7 +1068,7 @@ function App() {
|
||||
type OnboardingState = ReturnType<typeof useOnboarding>
|
||||
|
||||
/** Welcome screen view - extracted from main App component */
|
||||
function WelcomeView({ onboarding }: { onboarding: OnboardingState }) {
|
||||
function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; isOffline: boolean }) {
|
||||
const state = onboarding.state as { status: 'welcome' | 'vault-missing'; defaultPath: string; vaultPath?: string }
|
||||
return (
|
||||
<div className="app-shell">
|
||||
@@ -801,6 +1080,7 @@ function WelcomeView({ onboarding }: { onboarding: OnboardingState }) {
|
||||
onRetryCreateVault={onboarding.retryCreateVault}
|
||||
onCreateNewVault={onboarding.handleCreateNewVault}
|
||||
onOpenFolder={onboarding.handleOpenFolder}
|
||||
isOffline={isOffline}
|
||||
creatingAction={onboarding.creatingAction}
|
||||
error={onboarding.error}
|
||||
canRetryTemplate={onboarding.canRetryTemplate}
|
||||
@@ -809,6 +1089,20 @@ function WelcomeView({ onboarding }: { onboarding: OnboardingState }) {
|
||||
)
|
||||
}
|
||||
|
||||
function AiAgentsOnboardingView({
|
||||
statuses,
|
||||
onContinue,
|
||||
}: {
|
||||
statuses: ReturnType<typeof useAiAgentsStatus>
|
||||
onContinue: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<AiAgentsOnboardingPrompt statuses={statuses} onContinue={onContinue} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Loading spinner view - extracted from main App component */
|
||||
function LoadingView() {
|
||||
return (
|
||||
|
||||
68
src/components/AiAgentsOnboardingPrompt.test.tsx
Normal file
68
src/components/AiAgentsOnboardingPrompt.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AiAgentsOnboardingPrompt } from './AiAgentsOnboardingPrompt'
|
||||
|
||||
const openExternalUrl = vi.fn()
|
||||
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: (...args: unknown[]) => openExternalUrl(...args),
|
||||
}))
|
||||
|
||||
describe('AiAgentsOnboardingPrompt', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows the ready state when at least one agent is installed', () => {
|
||||
render(
|
||||
<AiAgentsOnboardingPrompt
|
||||
statuses={{
|
||||
claude_code: { status: 'installed', version: '1.0.20' },
|
||||
codex: { status: 'missing', version: null },
|
||||
}}
|
||||
onContinue={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('AI agents ready')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-install-codex')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue')
|
||||
})
|
||||
|
||||
it('shows the missing state when no agents are installed', () => {
|
||||
render(
|
||||
<AiAgentsOnboardingPrompt
|
||||
statuses={{
|
||||
claude_code: { status: 'missing', version: null },
|
||||
codex: { status: 'missing', version: null },
|
||||
}}
|
||||
onContinue={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('No AI agents detected')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('claude-onboarding-screen')).toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Code not detected')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-install-claude_code')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-install-codex')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue without it')
|
||||
})
|
||||
|
||||
it('opens the agent install links', () => {
|
||||
render(
|
||||
<AiAgentsOnboardingPrompt
|
||||
statuses={{
|
||||
claude_code: { status: 'missing', version: null },
|
||||
codex: { status: 'missing', version: null },
|
||||
}}
|
||||
onContinue={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('ai-agents-onboarding-install-claude_code'))
|
||||
fireEvent.click(screen.getByTestId('ai-agents-onboarding-install-codex'))
|
||||
|
||||
expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
|
||||
expect(openExternalUrl).toHaveBeenCalledWith('https://developers.openai.com/codex/cli')
|
||||
})
|
||||
})
|
||||
146
src/components/AiAgentsOnboardingPrompt.tsx
Normal file
146
src/components/AiAgentsOnboardingPrompt.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { ArrowUpRight, Bot, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
AI_AGENT_DEFINITIONS,
|
||||
getAiAgentDefinition,
|
||||
hasAnyInstalledAiAgent,
|
||||
isAiAgentsStatusChecking,
|
||||
type AiAgentsStatus,
|
||||
} from '../lib/aiAgents'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
import { Button } from './ui/button'
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card'
|
||||
|
||||
interface AiAgentsOnboardingPromptProps {
|
||||
statuses: AiAgentsStatus
|
||||
onContinue: () => void
|
||||
}
|
||||
|
||||
function getPromptCopy(statuses: AiAgentsStatus) {
|
||||
if (isAiAgentsStatusChecking(statuses)) {
|
||||
return {
|
||||
accentClassName: 'bg-slate-100 text-slate-600',
|
||||
description: 'Checking which AI agents are available on this machine.',
|
||||
icon: <Loader2 className="size-7 animate-spin" />,
|
||||
title: 'Checking AI agents',
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAnyInstalledAiAgent(statuses)) {
|
||||
return {
|
||||
accentClassName: 'bg-amber-100 text-amber-700',
|
||||
description: 'Tolaria works best with a local CLI AI agent installed.',
|
||||
icon: <Bot className="size-7" />,
|
||||
title: 'No AI agents detected',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accentClassName: 'bg-emerald-100 text-emerald-700',
|
||||
description: 'Your AI agents are ready to use in Tolaria.',
|
||||
icon: <CheckCircle2 className="size-7" />,
|
||||
title: 'AI agents ready',
|
||||
}
|
||||
}
|
||||
|
||||
function AgentStatusList({ statuses }: { statuses: AiAgentsStatus }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{AI_AGENT_DEFINITIONS.map((definition) => {
|
||||
const status = statuses[definition.id]
|
||||
const ready = status.status === 'installed'
|
||||
return (
|
||||
<div
|
||||
key={definition.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm"
|
||||
>
|
||||
<div className="space-y-1 text-left">
|
||||
<div className="font-medium text-foreground">{definition.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{ready
|
||||
? `${definition.label}${status.version ? ` ${status.version}` : ''} is ready.`
|
||||
: `${definition.label} is not installed yet.`}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 text-[11px] font-medium ${ready ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}
|
||||
>
|
||||
{ready ? 'Installed' : 'Missing'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiAgentsOnboardingPrompt({
|
||||
statuses,
|
||||
onContinue,
|
||||
}: AiAgentsOnboardingPromptProps) {
|
||||
const copy = getPromptCopy(statuses)
|
||||
const showLegacyClaudeCompatibility = statuses.claude_code.status !== 'installed'
|
||||
const missingAgents = AI_AGENT_DEFINITIONS.filter((definition) => statuses[definition.id].status === 'missing')
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center bg-sidebar px-6 py-10"
|
||||
data-testid="ai-agents-onboarding-screen"
|
||||
>
|
||||
<Card className="w-full max-w-2xl border-border bg-background shadow-sm">
|
||||
<CardHeader className="items-center gap-5 text-center">
|
||||
<div className={`flex size-16 items-center justify-center rounded-2xl ${copy.accentClassName}`}>
|
||||
{copy.icon}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="text-3xl tracking-tight">
|
||||
{copy.title}
|
||||
</CardTitle>
|
||||
<p className="text-sm leading-6 text-muted-foreground" data-testid="ai-agents-onboarding-description">
|
||||
{copy.description}
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{showLegacyClaudeCompatibility ? (
|
||||
<div
|
||||
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-left"
|
||||
data-testid="claude-onboarding-screen"
|
||||
>
|
||||
<div className="text-sm font-medium text-amber-900">Claude Code not detected</div>
|
||||
<p className="mt-1 text-xs leading-5 text-amber-800">
|
||||
Install Claude Code or continue without it.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<AgentStatusList statuses={statuses} />
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex-wrap justify-center gap-3">
|
||||
{missingAgents.map((definition) => (
|
||||
<Button
|
||||
key={definition.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void openExternalUrl(getAiAgentDefinition(definition.id).installUrl)}
|
||||
data-testid={`ai-agents-onboarding-install-${definition.id}`}
|
||||
>
|
||||
Install {definition.label}
|
||||
<ArrowUpRight className="size-4" />
|
||||
</Button>
|
||||
))}
|
||||
<div data-testid="ai-agents-onboarding-continue">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
disabled={isAiAgentsStatusChecking(statuses)}
|
||||
data-testid={showLegacyClaudeCompatibility ? 'claude-onboarding-continue' : undefined}
|
||||
>
|
||||
{hasAnyInstalledAiAgent(statuses) ? 'Continue' : 'Continue without it'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,13 +5,13 @@ import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt } from '../utils/aiPromptBridge'
|
||||
|
||||
// Mock the hooks and utils to isolate component tests
|
||||
let mockMessages: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['messages'] = []
|
||||
let mockStatus: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['status'] = 'idle'
|
||||
let mockMessages: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['messages'] = []
|
||||
let mockStatus: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['status'] = 'idle'
|
||||
const mockSendMessage = vi.fn()
|
||||
const mockClearConversation = vi.fn()
|
||||
|
||||
vi.mock('../hooks/useAiAgent', () => ({
|
||||
useAiAgent: () => ({
|
||||
vi.mock('../hooks/useCliAiAgent', () => ({
|
||||
useCliAiAgent: () => ({
|
||||
messages: mockMessages,
|
||||
status: mockStatus,
|
||||
sendMessage: mockSendMessage,
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import { type AiAgentMessage } from '../hooks/useAiAgent'
|
||||
import { type NoteReference, type NoteListItem } from '../utils/ai-context'
|
||||
import { useRef } from 'react'
|
||||
import {
|
||||
AiPanelComposer,
|
||||
AiPanelContextBar,
|
||||
AiPanelHeader,
|
||||
AiPanelMessageHistory,
|
||||
} from './AiPanelChrome'
|
||||
import { DEFAULT_AI_AGENT, getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents'
|
||||
import { type NoteListItem } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
|
||||
import { useAiPanelController } from './useAiPanelController'
|
||||
import { useAiPanelPromptQueue } from './useAiPanelPromptQueue'
|
||||
import { useAiPanelFocus } from './useAiPanelFocus'
|
||||
|
||||
export type { AiAgentMessage } from '../hooks/useAiAgent'
|
||||
export type { AiAgentMessage } from '../hooks/useCliAiAgent'
|
||||
|
||||
interface AiPanelProps {
|
||||
onClose: () => void
|
||||
onOpenNote?: (path: string) => void
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiAgentReady?: boolean
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
@@ -28,150 +32,28 @@ interface AiPanelProps {
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
}
|
||||
|
||||
function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center border-b border-border"
|
||||
style={{ height: 52, padding: '0 12px', gap: 8 }}
|
||||
>
|
||||
<Robot size={16} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
AI Chat
|
||||
</span>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onClear}
|
||||
title="New conversation"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onClose}
|
||||
title="Close AI panel"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextBar({ activeEntry, linkedCount }: { activeEntry: VaultEntry; linkedCount: number }) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center border-b border-border text-muted-foreground"
|
||||
style={{ padding: '6px 12px', gap: 6, fontSize: 11 }}
|
||||
data-testid="context-bar"
|
||||
>
|
||||
<Link size={12} className="shrink-0" />
|
||||
<span className="truncate" style={{ fontWeight: 500 }}>{activeEntry.title}</span>
|
||||
{linkedCount > 0 && (
|
||||
<span style={{ opacity: 0.6 }}>+ {linkedCount} linked</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ hasContext }: { hasContext: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center text-center text-muted-foreground"
|
||||
style={{ paddingTop: 40 }}
|
||||
>
|
||||
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
|
||||
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
|
||||
{hasContext
|
||||
? 'Ask about this note and its linked context'
|
||||
: 'Open a note, then ask the AI about it'
|
||||
}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
|
||||
{hasContext
|
||||
? 'Summarize, find connections, expand ideas'
|
||||
: 'The AI will use the active note as context'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, hasContext }: {
|
||||
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; onNavigateWikilink?: (target: string) => void; hasContext: boolean
|
||||
}) {
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, isActive])
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
|
||||
{messages.length === 0 && !isActive && <EmptyState hasContext={hasContext} />}
|
||||
{messages.map((msg, i) => (
|
||||
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} onNavigateWikilink={onNavigateWikilink} />
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AiPanelComposer({
|
||||
export function AiPanel({
|
||||
onClose,
|
||||
onOpenNote,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
vaultPath,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
hasContext,
|
||||
input,
|
||||
inputRef,
|
||||
isActive,
|
||||
onChange,
|
||||
onSend,
|
||||
}: {
|
||||
entries: VaultEntry[]
|
||||
hasContext: boolean
|
||||
input: string
|
||||
inputRef: React.RefObject<HTMLDivElement | null>
|
||||
isActive: boolean
|
||||
onChange: (value: string) => void
|
||||
onSend: (text: string, references: NoteReference[]) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<WikilinkChatInput
|
||||
entries={entries}
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={isActive}
|
||||
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
|
||||
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
|
||||
borderRadius: 8, width: 32, height: 34,
|
||||
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
|
||||
disabled={isActive || !input.trim()}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, openTabs, noteList, noteListFilter }: AiPanelProps) {
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
}: AiPanelProps) {
|
||||
const defaultAiAgent = providedDefaultAiAgent ?? DEFAULT_AI_AGENT
|
||||
const defaultAiAgentReady = providedDefaultAiAgentReady ?? true
|
||||
const useLegacyAiExperience = providedDefaultAiAgent === undefined && providedDefaultAiAgentReady === undefined
|
||||
const inputRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
const agentLabel = getAiAgentDefinition(defaultAiAgent).label
|
||||
|
||||
const {
|
||||
agent,
|
||||
@@ -184,6 +66,8 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on
|
||||
handleNavigateWikilink,
|
||||
} = useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent,
|
||||
defaultAiAgentReady,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
@@ -215,11 +99,14 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on
|
||||
data-testid="ai-panel"
|
||||
data-ai-active={isActive || undefined}
|
||||
>
|
||||
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
|
||||
<AiPanelHeader agentLabel={agentLabel} agentReady={defaultAiAgentReady} legacyCopy={useLegacyAiExperience} onClose={onClose} onClear={agent.clearConversation} />
|
||||
{activeEntry && (
|
||||
<ContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
|
||||
<AiPanelContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
|
||||
)}
|
||||
<MessageHistory
|
||||
<AiPanelMessageHistory
|
||||
agentLabel={agentLabel}
|
||||
agentReady={defaultAiAgentReady}
|
||||
legacyCopy={useLegacyAiExperience}
|
||||
messages={agent.messages}
|
||||
isActive={isActive}
|
||||
onOpenNote={onOpenNote}
|
||||
@@ -228,10 +115,13 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on
|
||||
/>
|
||||
<AiPanelComposer
|
||||
entries={entries ?? []}
|
||||
agentLabel={agentLabel}
|
||||
agentReady={defaultAiAgentReady}
|
||||
hasContext={hasContext}
|
||||
input={input}
|
||||
inputRef={inputRef}
|
||||
isActive={isActive}
|
||||
legacyCopy={useLegacyAiExperience}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
|
||||
260
src/components/AiPanelChrome.tsx
Normal file
260
src/components/AiPanelChrome.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
|
||||
import type { AiAgentMessage } from '../hooks/useCliAiAgent'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
interface AiPanelHeaderProps {
|
||||
agentLabel: string
|
||||
agentReady: boolean
|
||||
legacyCopy: boolean
|
||||
onClose: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
interface AiPanelContextBarProps {
|
||||
activeEntry: VaultEntry
|
||||
linkedCount: number
|
||||
}
|
||||
|
||||
interface AiPanelMessageHistoryProps {
|
||||
agentLabel: string
|
||||
agentReady: boolean
|
||||
legacyCopy: boolean
|
||||
messages: AiAgentMessage[]
|
||||
isActive: boolean
|
||||
onOpenNote?: (path: string) => void
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
hasContext: boolean
|
||||
}
|
||||
|
||||
interface AiPanelComposerProps {
|
||||
entries: VaultEntry[]
|
||||
agentLabel: string
|
||||
agentReady: boolean
|
||||
hasContext: boolean
|
||||
input: string
|
||||
inputRef: React.RefObject<HTMLDivElement | null>
|
||||
isActive: boolean
|
||||
legacyCopy: boolean
|
||||
onChange: (value: string) => void
|
||||
onSend: (text: string, references: NoteReference[]) => void
|
||||
}
|
||||
|
||||
function getComposerPlaceholder(
|
||||
agentLabel: string,
|
||||
agentReady: boolean,
|
||||
legacyCopy: boolean,
|
||||
hasContext: boolean,
|
||||
): string {
|
||||
if (!agentReady) {
|
||||
return `${agentLabel} is not installed. Open AI Agents in Settings.`
|
||||
}
|
||||
|
||||
if (legacyCopy) {
|
||||
return hasContext ? 'Ask about this note...' : 'Ask the AI agent...'
|
||||
}
|
||||
|
||||
return hasContext ? `Ask ${agentLabel} about this note...` : `Ask ${agentLabel}...`
|
||||
}
|
||||
|
||||
function AiPanelEmptyState({
|
||||
agentLabel,
|
||||
agentReady,
|
||||
hasContext,
|
||||
legacyCopy,
|
||||
}: Pick<AiPanelMessageHistoryProps, 'agentLabel' | 'agentReady' | 'hasContext' | 'legacyCopy'>) {
|
||||
if (!agentReady) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center text-center text-muted-foreground"
|
||||
style={{ paddingTop: 40 }}
|
||||
>
|
||||
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
|
||||
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
|
||||
{agentLabel} is not available on this machine
|
||||
</p>
|
||||
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
|
||||
Install it or switch the default AI agent in Settings
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center text-center text-muted-foreground"
|
||||
style={{ paddingTop: 40 }}
|
||||
>
|
||||
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
|
||||
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
|
||||
{hasContext
|
||||
? legacyCopy ? 'Ask about this note and its linked context' : `Ask ${agentLabel} about this note and its linked context`
|
||||
: legacyCopy ? 'Open a note, then ask the AI about it' : `Open a note, then ask ${agentLabel} about it`
|
||||
}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
|
||||
{hasContext
|
||||
? 'Summarize, find connections, expand ideas'
|
||||
: 'The AI will use the active note as context'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanelHeader({
|
||||
agentLabel,
|
||||
agentReady,
|
||||
legacyCopy,
|
||||
onClose,
|
||||
onClear,
|
||||
}: AiPanelHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center border-b border-border"
|
||||
style={{ height: 52, padding: '0 12px', gap: 8 }}
|
||||
>
|
||||
<Robot size={16} className="shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
{legacyCopy ? 'AI Chat' : 'AI Agent'}
|
||||
</span>
|
||||
{!legacyCopy && (
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{agentLabel}
|
||||
{!agentReady ? ' · not installed' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onClear}
|
||||
title="New conversation"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onClose}
|
||||
title="Close AI panel"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanelContextBar({ activeEntry, linkedCount }: AiPanelContextBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center border-b border-border text-muted-foreground"
|
||||
style={{ padding: '6px 12px', gap: 6, fontSize: 11 }}
|
||||
data-testid="context-bar"
|
||||
>
|
||||
<Link size={12} className="shrink-0" />
|
||||
<span className="truncate" style={{ fontWeight: 500 }}>{activeEntry.title}</span>
|
||||
{linkedCount > 0 && (
|
||||
<span style={{ opacity: 0.6 }}>+ {linkedCount} linked</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanelMessageHistory({
|
||||
agentLabel,
|
||||
agentReady,
|
||||
legacyCopy,
|
||||
messages,
|
||||
isActive,
|
||||
onOpenNote,
|
||||
onNavigateWikilink,
|
||||
hasContext,
|
||||
}: AiPanelMessageHistoryProps) {
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, isActive])
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
|
||||
{messages.length === 0 && !isActive && (
|
||||
<AiPanelEmptyState
|
||||
agentLabel={agentLabel}
|
||||
agentReady={agentReady}
|
||||
legacyCopy={legacyCopy}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
)}
|
||||
{messages.map((message, index) => (
|
||||
<AiMessage
|
||||
key={message.id ?? index}
|
||||
{...message}
|
||||
onOpenNote={onOpenNote}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
/>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanelComposer({
|
||||
entries,
|
||||
agentLabel,
|
||||
agentReady,
|
||||
hasContext,
|
||||
input,
|
||||
inputRef,
|
||||
isActive,
|
||||
legacyCopy,
|
||||
onChange,
|
||||
onSend,
|
||||
}: AiPanelComposerProps) {
|
||||
const composerDisabled = isActive || !agentReady
|
||||
const canSend = !composerDisabled && input.trim().length > 0
|
||||
const placeholder = getComposerPlaceholder(agentLabel, agentReady, legacyCopy, hasContext)
|
||||
const sendButtonStyle = {
|
||||
background: canSend ? 'var(--primary)' : 'var(--muted)',
|
||||
color: canSend ? 'white' : 'var(--muted-foreground)',
|
||||
borderRadius: 8,
|
||||
width: 32,
|
||||
height: 34,
|
||||
cursor: canSend ? 'pointer' : 'not-allowed',
|
||||
} as const
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<WikilinkChatInput
|
||||
entries={entries}
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
disabled={composerDisabled}
|
||||
placeholder={placeholder}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={sendButtonStyle}
|
||||
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
|
||||
disabled={!canSend}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,15 +5,20 @@ import { BulkActionBar } from './BulkActionBar'
|
||||
describe('BulkActionBar', () => {
|
||||
const defaultProps = {
|
||||
count: 3,
|
||||
onOrganize: vi.fn(),
|
||||
onArchive: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onClear: vi.fn(),
|
||||
}
|
||||
|
||||
it('shows Archive and Delete buttons in normal view', () => {
|
||||
it('shows icon-only organize, archive, and delete buttons in normal view', () => {
|
||||
render(<BulkActionBar {...defaultProps} />)
|
||||
expect(screen.getByTestId('bulk-organize-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-archive-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-delete-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Organize')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Archive')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Delete')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows selected count', () => {
|
||||
@@ -21,13 +26,64 @@ describe('BulkActionBar', () => {
|
||||
expect(screen.getByText('5 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Unarchive and Delete buttons in archived view', () => {
|
||||
it('shows organize, unarchive, and delete buttons in archived view', () => {
|
||||
render(<BulkActionBar {...defaultProps} isArchivedView={true} />)
|
||||
expect(screen.getByTestId('bulk-organize-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-unarchive-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-delete-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-archive-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exposes accessible names and a destructive variant for icon-only actions', () => {
|
||||
render(<BulkActionBar {...defaultProps} />)
|
||||
expect(screen.getByRole('button', { name: 'Organize selected notes' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Archive selected notes' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Permanently delete selected notes' })).toHaveAttribute('data-variant', 'destructive')
|
||||
expect(screen.getByRole('button', { name: 'Clear selection' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the icon-only controls focusable and activatable', () => {
|
||||
const onOrganize = vi.fn()
|
||||
const onArchive = vi.fn()
|
||||
const onDelete = vi.fn()
|
||||
const onClear = vi.fn()
|
||||
|
||||
render(
|
||||
<BulkActionBar
|
||||
{...defaultProps}
|
||||
onOrganize={onOrganize}
|
||||
onArchive={onArchive}
|
||||
onDelete={onDelete}
|
||||
onClear={onClear}
|
||||
/>,
|
||||
)
|
||||
|
||||
const organizeButton = screen.getByRole('button', { name: 'Organize selected notes' })
|
||||
const archiveButton = screen.getByRole('button', { name: 'Archive selected notes' })
|
||||
const deleteButton = screen.getByRole('button', { name: 'Permanently delete selected notes' })
|
||||
const clearButton = screen.getByRole('button', { name: 'Clear selection' })
|
||||
|
||||
organizeButton.focus()
|
||||
expect(organizeButton).toHaveFocus()
|
||||
fireEvent.click(organizeButton)
|
||||
expect(onOrganize).toHaveBeenCalledTimes(1)
|
||||
|
||||
archiveButton.focus()
|
||||
expect(archiveButton).toHaveFocus()
|
||||
fireEvent.click(archiveButton)
|
||||
expect(onArchive).toHaveBeenCalledTimes(1)
|
||||
|
||||
deleteButton.focus()
|
||||
expect(deleteButton).toHaveFocus()
|
||||
fireEvent.click(deleteButton)
|
||||
expect(onDelete).toHaveBeenCalledTimes(1)
|
||||
|
||||
clearButton.focus()
|
||||
expect(clearButton).toHaveFocus()
|
||||
fireEvent.click(clearButton)
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onUnarchive when Unarchive button clicked in archived view', () => {
|
||||
const onUnarchive = vi.fn()
|
||||
render(<BulkActionBar {...defaultProps} isArchivedView={true} onUnarchive={onUnarchive} />)
|
||||
|
||||
@@ -1,45 +1,74 @@
|
||||
import { memo } from 'react'
|
||||
import { Archive, ArrowCounterClockwise, Trash, X } from '@phosphor-icons/react'
|
||||
import { Archive, ArrowCounterClockwise, CheckCircle, Trash, X } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface BulkActionBarProps {
|
||||
count: number
|
||||
isArchivedView?: boolean
|
||||
onOrganize?: () => void
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onUnarchive?: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
const actionBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(255,255,255,0.12)', color: 'inherit', fontSize: 12, fontWeight: 500 } as const
|
||||
const destructiveBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(224,62,62,0.2)', color: 'var(--destructive)', fontSize: 12, fontWeight: 500 } as const
|
||||
interface BulkActionButtonProps {
|
||||
ariaLabel: string
|
||||
children: React.ReactNode
|
||||
destructive?: boolean
|
||||
onClick?: () => void
|
||||
testId: string
|
||||
}
|
||||
|
||||
function BulkActionButton({ ariaLabel, children, destructive = false, onClick, testId }: BulkActionButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant={destructive ? 'destructive' : 'ghost'}
|
||||
className={
|
||||
destructive
|
||||
? 'h-8 w-8 rounded-lg bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/30'
|
||||
: 'h-8 w-8 rounded-lg bg-white/10 text-background hover:bg-white/20 focus-visible:ring-white/35 disabled:bg-white/5 disabled:text-white/35'
|
||||
}
|
||||
onClick={onClick}
|
||||
disabled={!onClick}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPrimaryActions(
|
||||
isArchivedView: boolean,
|
||||
onOrganize: (() => void) | undefined,
|
||||
onArchive: () => void,
|
||||
onDelete: () => void,
|
||||
onUnarchive: (() => void) | undefined,
|
||||
) {
|
||||
const archiveLabel = isArchivedView ? 'Unarchive selected notes' : 'Archive selected notes'
|
||||
const archiveIcon = isArchivedView ? <ArrowCounterClockwise size={16} /> : <Archive size={16} />
|
||||
const archiveHandler = isArchivedView ? onUnarchive : onArchive
|
||||
|
||||
function renderArchivedActions(onUnarchive: (() => void) | undefined, onDelete: () => void) {
|
||||
return (
|
||||
<>
|
||||
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onUnarchive} title="Unarchive selected notes" data-testid="bulk-unarchive-btn">
|
||||
<ArrowCounterClockwise size={14} /> Unarchive
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onDelete} title="Permanently delete selected notes" data-testid="bulk-delete-btn">
|
||||
<Trash size={14} /> Delete
|
||||
</button>
|
||||
<BulkActionButton ariaLabel="Organize selected notes" onClick={onOrganize} testId="bulk-organize-btn">
|
||||
<CheckCircle size={16} weight="fill" />
|
||||
</BulkActionButton>
|
||||
<BulkActionButton ariaLabel={archiveLabel} onClick={archiveHandler} testId={isArchivedView ? 'bulk-unarchive-btn' : 'bulk-archive-btn'}>
|
||||
{archiveIcon}
|
||||
</BulkActionButton>
|
||||
<BulkActionButton ariaLabel="Permanently delete selected notes" destructive onClick={onDelete} testId="bulk-delete-btn">
|
||||
<Trash size={16} />
|
||||
</BulkActionButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDefaultActions(onArchive: () => void, onDelete: () => void) {
|
||||
return (
|
||||
<>
|
||||
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onArchive} title="Archive selected notes" data-testid="bulk-archive-btn">
|
||||
<Archive size={14} /> Archive
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onDelete} title="Permanently delete selected notes" data-testid="bulk-delete-btn">
|
||||
<Trash size={14} /> Delete
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BulkActionBarInner({ count, isArchivedView, onArchive, onDelete, onUnarchive, onClear }: BulkActionBarProps) {
|
||||
function BulkActionBarInner({ count, isArchivedView, onOrganize, onArchive, onDelete, onUnarchive, onClear }: BulkActionBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
@@ -54,18 +83,20 @@ function BulkActionBarInner({ count, isArchivedView, onArchive, onDelete, onUnar
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{count} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{isArchivedView ? renderArchivedActions(onUnarchive, onDelete)
|
||||
: renderDefaultActions(onArchive, onDelete)}
|
||||
<button
|
||||
className="flex items-center border-none bg-transparent cursor-pointer"
|
||||
style={{ padding: '5px 6px', color: 'rgba(255,255,255,0.5)' }}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{renderPrimaryActions(Boolean(isArchivedView), onOrganize, onArchive, onDelete, onUnarchive)}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 rounded-lg text-white/55 hover:bg-white/10 hover:text-background focus-visible:ring-white/30"
|
||||
onClick={onClear}
|
||||
aria-label="Clear selection"
|
||||
title="Clear selection"
|
||||
data-testid="bulk-clear-btn"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
56
src/components/ClaudeCodeOnboardingPrompt.test.tsx
Normal file
56
src/components/ClaudeCodeOnboardingPrompt.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ClaudeCodeOnboardingPrompt } from './ClaudeCodeOnboardingPrompt'
|
||||
|
||||
const openExternalUrl = vi.fn()
|
||||
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: (...args: unknown[]) => openExternalUrl(...args),
|
||||
}))
|
||||
|
||||
describe('ClaudeCodeOnboardingPrompt', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows the detected state with a continue action', () => {
|
||||
render(<ClaudeCodeOnboardingPrompt status="installed" onContinue={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Claude Code detected')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('claude-onboarding-continue')).toHaveTextContent('Continue')
|
||||
expect(screen.queryByTestId('claude-onboarding-install')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the install path when Claude Code is missing', () => {
|
||||
render(<ClaudeCodeOnboardingPrompt status="missing" onContinue={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Claude Code not detected')).toBeInTheDocument()
|
||||
expect(screen.getByText('Install Claude Code to enable AI-powered note management.')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('claude-onboarding-install')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('claude-onboarding-continue')).toHaveTextContent('Continue without it')
|
||||
})
|
||||
|
||||
it('opens the Claude Code install page', () => {
|
||||
render(<ClaudeCodeOnboardingPrompt status="missing" onContinue={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('claude-onboarding-install'))
|
||||
|
||||
expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
|
||||
})
|
||||
|
||||
it('calls onContinue from the detected state', () => {
|
||||
const onContinue = vi.fn()
|
||||
render(<ClaudeCodeOnboardingPrompt status="installed" onContinue={onContinue} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('claude-onboarding-continue'))
|
||||
|
||||
expect(onContinue).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables continue while detection is still running', () => {
|
||||
render(<ClaudeCodeOnboardingPrompt status="checking" onContinue={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Checking for Claude Code')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('claude-onboarding-continue')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
101
src/components/ClaudeCodeOnboardingPrompt.tsx
Normal file
101
src/components/ClaudeCodeOnboardingPrompt.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { ArrowUpRight, Bot, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
import { Button } from './ui/button'
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card'
|
||||
|
||||
const CLAUDE_CODE_INSTALL_URL = 'https://docs.anthropic.com/en/docs/claude-code'
|
||||
|
||||
interface ClaudeCodeOnboardingPromptProps {
|
||||
status: ClaudeCodeStatus
|
||||
onContinue: () => void
|
||||
}
|
||||
|
||||
function getPromptCopy(status: ClaudeCodeStatus) {
|
||||
if (status === 'installed') {
|
||||
return {
|
||||
accentClassName: 'bg-emerald-100 text-emerald-700',
|
||||
description: "Tolaria's AI features are ready to use.",
|
||||
icon: <CheckCircle2 className="size-7" />,
|
||||
title: 'Claude Code detected',
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'missing') {
|
||||
return {
|
||||
accentClassName: 'bg-amber-100 text-amber-700',
|
||||
description: 'Tolaria works best with an AI coding agent installed.',
|
||||
icon: <Bot className="size-7" />,
|
||||
title: 'Claude Code not detected',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accentClassName: 'bg-slate-100 text-slate-600',
|
||||
description: 'Checking whether Claude Code is available on this machine.',
|
||||
icon: <Loader2 className="size-7 animate-spin" />,
|
||||
title: 'Checking for Claude Code',
|
||||
}
|
||||
}
|
||||
|
||||
export function ClaudeCodeOnboardingPrompt({
|
||||
status,
|
||||
onContinue,
|
||||
}: ClaudeCodeOnboardingPromptProps) {
|
||||
const copy = getPromptCopy(status)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center bg-sidebar px-6 py-10"
|
||||
data-testid="claude-onboarding-screen"
|
||||
>
|
||||
<Card className="w-full max-w-2xl border-border bg-background shadow-sm">
|
||||
<CardHeader className="items-center gap-5 text-center">
|
||||
<div className={`flex size-16 items-center justify-center rounded-2xl ${copy.accentClassName}`}>
|
||||
{copy.icon}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<CardTitle className="text-3xl tracking-tight">
|
||||
{copy.title}
|
||||
</CardTitle>
|
||||
<p className="text-sm leading-6 text-muted-foreground" data-testid="claude-onboarding-description">
|
||||
{status === 'installed' && '✅ '}
|
||||
{status === 'missing' && '🤖 '}
|
||||
{copy.description}
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 text-center">
|
||||
{status === 'missing' && (
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Install Claude Code to enable AI-powered note management.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-center gap-3">
|
||||
{status === 'missing' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void openExternalUrl(CLAUDE_CODE_INSTALL_URL)}
|
||||
data-testid="claude-onboarding-install"
|
||||
>
|
||||
Install Claude Code
|
||||
<ArrowUpRight className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
disabled={status === 'checking'}
|
||||
data-testid="claude-onboarding-continue"
|
||||
>
|
||||
{status === 'missing' ? 'Continue without it' : status === 'installed' ? 'Continue' : 'Checking…'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
src/components/CloneVaultModal.test.tsx
Normal file
85
src/components/CloneVaultModal.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { CloneVaultModal } from './CloneVaultModal'
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn(),
|
||||
}))
|
||||
|
||||
import { mockInvoke } from '../mock-tauri'
|
||||
|
||||
const mockInvokeFn = vi.mocked(mockInvoke)
|
||||
|
||||
describe('CloneVaultModal', () => {
|
||||
const onClose = vi.fn()
|
||||
const onVaultCloned = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInvokeFn.mockResolvedValue('Cloned successfully')
|
||||
})
|
||||
|
||||
it('renders nothing when not open', () => {
|
||||
const { container } = render(
|
||||
<CloneVaultModal open={false} onClose={onClose} onVaultCloned={onVaultCloned} />
|
||||
)
|
||||
|
||||
expect(container.querySelector('[data-testid="clone-vault-modal"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the clone form when open', () => {
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
expect(screen.getByText('Clone Git Repo')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('clone-repo-url')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('clone-vault-path')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('suggests a vault path from the repository URL', () => {
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('clone-repo-url'), {
|
||||
target: { value: 'https://gitlab.com/user/my-vault.git' },
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('clone-vault-path')).toHaveValue('~/Vaults/my-vault')
|
||||
})
|
||||
|
||||
it('calls clone_repo and reports the cloned vault on submit', async () => {
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('clone-repo-url'), {
|
||||
target: { value: 'git@github.com:user/my-vault.git' },
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('clone-vault-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('clone_repo', {
|
||||
url: 'git@github.com:user/my-vault.git',
|
||||
localPath: '~/Vaults/my-vault',
|
||||
})
|
||||
})
|
||||
|
||||
expect(onVaultCloned).toHaveBeenCalledWith('~/Vaults/my-vault', 'my-vault')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the backend error when cloning fails', async () => {
|
||||
mockInvokeFn.mockRejectedValueOnce(new Error('Permission denied'))
|
||||
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('clone-repo-url'), {
|
||||
target: { value: 'https://example.com/user/private-vault.git' },
|
||||
})
|
||||
fireEvent.change(screen.getByTestId('clone-vault-path'), {
|
||||
target: { value: '~/Vaults/private-vault' },
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('clone-vault-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('clone-vault-error')).toHaveTextContent('Clone failed: Error: Permission denied')
|
||||
})
|
||||
})
|
||||
})
|
||||
204
src/components/CloneVaultModal.tsx
Normal file
204
src/components/CloneVaultModal.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
|
||||
type CloneStatus = 'idle' | 'cloning' | 'error'
|
||||
|
||||
interface CloneVaultModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onVaultCloned: (path: string, label: string) => void
|
||||
}
|
||||
|
||||
interface CloneVaultFormState {
|
||||
repoUrl: string
|
||||
localPath: string
|
||||
cloneStatus: CloneStatus
|
||||
cloneError: string | null
|
||||
isCloneDisabled: boolean
|
||||
handleClose: () => void
|
||||
handleRepoUrlChange: (value: string) => void
|
||||
handleLocalPathChange: (value: string) => void
|
||||
handleClone: () => Promise<void>
|
||||
}
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
function repoNameFromUrl(url: string): string {
|
||||
const trimmed = url.trim().replace(/\/+$/g, '')
|
||||
if (!trimmed) return ''
|
||||
const segment = trimmed.split(/[/:]/).pop() ?? ''
|
||||
return segment.replace(/\.git$/i, '')
|
||||
}
|
||||
|
||||
function suggestedPathFromUrl(url: string): string {
|
||||
const repoName = repoNameFromUrl(url)
|
||||
return repoName ? `~/Vaults/${repoName}` : ''
|
||||
}
|
||||
|
||||
function labelFromPath(path: string): string {
|
||||
const trimmed = path.trim().replace(/\/+$/g, '')
|
||||
return trimmed.split('/').pop() || 'Vault'
|
||||
}
|
||||
|
||||
function shouldSyncSuggestedPath(localPath: string, pathDirty: boolean, previousSuggestedPath: string): boolean {
|
||||
return !pathDirty || !localPath.trim() || localPath === previousSuggestedPath
|
||||
}
|
||||
|
||||
function useCloneVaultForm(onClose: () => void, onVaultCloned: (path: string, label: string) => void): CloneVaultFormState {
|
||||
const [repoUrl, setRepoUrl] = useState('')
|
||||
const [localPath, setLocalPath] = useState('')
|
||||
const [pathDirty, setPathDirty] = useState(false)
|
||||
const [cloneStatus, setCloneStatus] = useState<CloneStatus>('idle')
|
||||
const [cloneError, setCloneError] = useState<string | null>(null)
|
||||
const previousSuggestedPathRef = useRef('')
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setRepoUrl('')
|
||||
setLocalPath('')
|
||||
setPathDirty(false)
|
||||
setCloneStatus('idle')
|
||||
setCloneError(null)
|
||||
previousSuggestedPathRef.current = ''
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [onClose, resetState])
|
||||
|
||||
const handleRepoUrlChange = useCallback((value: string) => {
|
||||
setRepoUrl(value)
|
||||
setCloneError(null)
|
||||
|
||||
const nextSuggestedPath = suggestedPathFromUrl(value)
|
||||
const previousSuggestedPath = previousSuggestedPathRef.current
|
||||
|
||||
if (shouldSyncSuggestedPath(localPath, pathDirty, previousSuggestedPath)) {
|
||||
setLocalPath(nextSuggestedPath)
|
||||
}
|
||||
|
||||
previousSuggestedPathRef.current = nextSuggestedPath
|
||||
}, [localPath, pathDirty])
|
||||
|
||||
const handleLocalPathChange = useCallback((value: string) => {
|
||||
setPathDirty(true)
|
||||
setLocalPath(value)
|
||||
setCloneError(null)
|
||||
}, [])
|
||||
|
||||
const handleClone = useCallback(async () => {
|
||||
const trimmedUrl = repoUrl.trim()
|
||||
const trimmedPath = localPath.trim()
|
||||
if (!trimmedUrl || !trimmedPath) return
|
||||
|
||||
setCloneStatus('cloning')
|
||||
setCloneError(null)
|
||||
|
||||
try {
|
||||
await tauriCall<string>('clone_repo', { url: trimmedUrl, localPath: trimmedPath })
|
||||
onVaultCloned(trimmedPath, labelFromPath(trimmedPath))
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
setCloneStatus('error')
|
||||
setCloneError(`Clone failed: ${String(error)}`)
|
||||
}
|
||||
}, [handleClose, localPath, onVaultCloned, repoUrl])
|
||||
|
||||
return {
|
||||
repoUrl,
|
||||
localPath,
|
||||
cloneStatus,
|
||||
cloneError,
|
||||
isCloneDisabled: !repoUrl.trim() || !localPath.trim() || cloneStatus === 'cloning',
|
||||
handleClose,
|
||||
handleRepoUrlChange,
|
||||
handleLocalPathChange,
|
||||
handleClone,
|
||||
}
|
||||
}
|
||||
|
||||
export function CloneVaultModal({ open, onClose, onVaultCloned }: CloneVaultModalProps) {
|
||||
const {
|
||||
repoUrl,
|
||||
localPath,
|
||||
cloneStatus,
|
||||
cloneError,
|
||||
isCloneDisabled,
|
||||
handleClose,
|
||||
handleRepoUrlChange,
|
||||
handleLocalPathChange,
|
||||
handleClone,
|
||||
} = useCloneVaultForm(onClose, onVaultCloned)
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen) handleClose()
|
||||
}, [handleClose])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[520px]" data-testid="clone-vault-modal">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clone Git Repo</DialogTitle>
|
||||
<DialogDescription>
|
||||
Clone any remote repository into a local vault folder. Tolaria uses your existing system git
|
||||
configuration for authentication.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground" htmlFor="clone-repo-url">Repository URL</label>
|
||||
<Input
|
||||
id="clone-repo-url"
|
||||
placeholder="git@host:owner/repo.git or https://host/owner/repo.git"
|
||||
value={repoUrl}
|
||||
onChange={(event) => handleRepoUrlChange(event.target.value)}
|
||||
data-testid="clone-repo-url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground" htmlFor="clone-vault-path">Clone to</label>
|
||||
<Input
|
||||
id="clone-vault-path"
|
||||
placeholder="~/Vaults/my-vault"
|
||||
value={localPath}
|
||||
onChange={(event) => handleLocalPathChange(event.target.value)}
|
||||
data-testid="clone-vault-path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
SSH keys, the git credential manager, `gh auth`, and other system git auth methods all work.
|
||||
</p>
|
||||
|
||||
{cloneError && (
|
||||
<p className="text-xs text-destructive" data-testid="clone-vault-error">{cloneError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-end sm:justify-end">
|
||||
<Button
|
||||
onClick={handleClone}
|
||||
disabled={isCloneDisabled}
|
||||
data-testid="clone-vault-submit"
|
||||
>
|
||||
{cloneStatus === 'cloning' ? 'Cloning...' : 'Clone & Open'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
|
||||
import { readSelectionRange } from './inlineWikilinkDom'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
import type { CommandAction } from '../hooks/useCommandRegistry'
|
||||
|
||||
@@ -235,6 +236,28 @@ describe('CommandPalette', () => {
|
||||
expect(screen.queryByText('Search Notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('focuses the AI editor immediately when the leading space triggers AI mode', () => {
|
||||
render(<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('Type a command...')
|
||||
input.focus()
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
|
||||
expect(screen.getByTestId('command-palette-ai-input')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('places the AI editor caret after the trigger space on mode entry', () => {
|
||||
render(<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('Type a command...')
|
||||
input.focus()
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
|
||||
const editor = screen.getByTestId('command-palette-ai-input') as HTMLDivElement
|
||||
expect(editor).toHaveFocus()
|
||||
expect(readSelectionRange(editor)).toEqual({ start: 1, end: 1 })
|
||||
})
|
||||
|
||||
it('returns to command mode when the leading space is deleted', () => {
|
||||
render(
|
||||
<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { fuzzyMatch } from '../utils/fuzzyMatch'
|
||||
@@ -13,6 +13,8 @@ interface CommandPaletteProps {
|
||||
commands: CommandAction[]
|
||||
entries?: VaultEntry[]
|
||||
claudeCodeReady?: boolean
|
||||
aiAgentReady?: boolean
|
||||
aiAgentLabel?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -21,6 +23,23 @@ interface ScoredCommand {
|
||||
score: number
|
||||
}
|
||||
|
||||
function focusPaletteTarget(target: HTMLInputElement | HTMLDivElement | null) {
|
||||
if (!target) return
|
||||
|
||||
target.focus()
|
||||
|
||||
if (!(target instanceof HTMLDivElement)) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(target)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function matchCommand(query: string, command: CommandAction): ScoredCommand | null {
|
||||
const labelResult = fuzzyMatch(query, command.label)
|
||||
if (labelResult.match) return { command, score: labelResult.score }
|
||||
@@ -173,12 +192,14 @@ function CommandPaletteResults({
|
||||
|
||||
function CommandPaletteFooter({
|
||||
aiMode,
|
||||
aiAgentLabel = 'Claude Code',
|
||||
}: {
|
||||
aiMode: boolean
|
||||
aiAgentLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>{aiMode ? 'Claude mode' : '↑↓ navigate'}</span>
|
||||
<span>{aiMode ? `${aiAgentLabel} mode` : '↑↓ navigate'}</span>
|
||||
<span>{aiMode ? '↵ send' : '↵ select'}</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
@@ -194,6 +215,8 @@ function OpenCommandPalette({
|
||||
commands,
|
||||
entries = [],
|
||||
claudeCodeReady = true,
|
||||
aiAgentReady,
|
||||
aiAgentLabel = 'Claude Code',
|
||||
onClose,
|
||||
}: Omit<CommandPaletteProps, 'open'>) {
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -203,17 +226,20 @@ function OpenCommandPalette({
|
||||
const aiInputRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const aiMode = aiValue.startsWith(' ')
|
||||
const resolvedAiAgentReady = aiAgentReady ?? claudeCodeReady
|
||||
const { groups, flatList } = usePaletteResults(commands, query)
|
||||
|
||||
useEffect(() => {
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
if (aiMode) {
|
||||
aiInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
inputRef.current?.focus()
|
||||
}, 50)
|
||||
return () => window.clearTimeout(focusTimer)
|
||||
useLayoutEffect(() => {
|
||||
const target = aiMode ? aiInputRef.current : inputRef.current
|
||||
if (!target) return
|
||||
|
||||
focusPaletteTarget(target)
|
||||
if (document.activeElement === target) return
|
||||
|
||||
const focusRetry = window.requestAnimationFrame(() => {
|
||||
focusPaletteTarget(target)
|
||||
})
|
||||
return () => window.cancelAnimationFrame(focusRetry)
|
||||
}, [aiMode])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -290,7 +316,7 @@ function OpenCommandPalette({
|
||||
return
|
||||
}
|
||||
|
||||
if (!claudeCodeReady) return
|
||||
if (!resolvedAiAgentReady) return
|
||||
|
||||
queueAiPrompt(text, references)
|
||||
requestOpenAiChat()
|
||||
@@ -314,6 +340,9 @@ function OpenCommandPalette({
|
||||
entries={entries}
|
||||
value={aiValue}
|
||||
claudeCodeReady={claudeCodeReady}
|
||||
aiAgentReady={resolvedAiAgentReady}
|
||||
aiAgentLabel={aiAgentLabel}
|
||||
inputRef={aiInputRef}
|
||||
onChange={handleAiValueChange}
|
||||
onSubmit={handleSubmitAiPrompt}
|
||||
/>
|
||||
@@ -327,7 +356,7 @@ function OpenCommandPalette({
|
||||
onHover={setSelectedIndex}
|
||||
onSelect={handleSelectCommand}
|
||||
/>
|
||||
<CommandPaletteFooter aiMode={false} />
|
||||
<CommandPaletteFooter aiMode={false} aiAgentLabel={aiAgentLabel} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,9 @@ interface CommandPaletteAiModeProps {
|
||||
entries: VaultEntry[]
|
||||
value: string
|
||||
claudeCodeReady: boolean
|
||||
aiAgentReady?: boolean
|
||||
aiAgentLabel?: string
|
||||
inputRef?: React.RefObject<HTMLDivElement | null>
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (text: string, references: NoteReference[]) => void
|
||||
}
|
||||
@@ -19,33 +22,39 @@ export function CommandPaletteAiMode({
|
||||
entries,
|
||||
value,
|
||||
claudeCodeReady,
|
||||
aiAgentReady,
|
||||
aiAgentLabel = 'Claude Code',
|
||||
inputRef,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: CommandPaletteAiModeProps) {
|
||||
const resolvedAiAgentReady = aiAgentReady ?? claudeCodeReady
|
||||
|
||||
return (
|
||||
<InlineWikilinkInput
|
||||
entries={entries}
|
||||
value={value}
|
||||
inputRef={inputRef}
|
||||
onChange={onChange}
|
||||
onSubmit={(text, references) => onSubmit(stripLeadingSpace(text), references)}
|
||||
submitOnEmpty={true}
|
||||
placeholder="Ask Claude Code..."
|
||||
placeholder={`Ask ${aiAgentLabel}...`}
|
||||
dataTestId="command-palette-ai-input"
|
||||
editorClassName="border-none px-0 py-0 text-[15px]"
|
||||
suggestionListVariant="palette"
|
||||
paletteHeader={(
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<Sparkle size={12} weight="fill" />
|
||||
<span>Ask Claude Code</span>
|
||||
<span>Ask {aiAgentLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
paletteEmptyState={(
|
||||
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
||||
{!claudeCodeReady ? (
|
||||
'Claude Code is not available on this machine.'
|
||||
{!resolvedAiAgentReady ? (
|
||||
`${aiAgentLabel} is not available on this machine.`
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 font-medium text-foreground">Ask Claude Code</div>
|
||||
<div className="mb-1 font-medium text-foreground">Ask {aiAgentLabel}</div>
|
||||
<div>
|
||||
{value.trim().length === 0
|
||||
? 'Type your prompt after the leading space.'
|
||||
@@ -57,7 +66,7 @@ export function CommandPaletteAiMode({
|
||||
)}
|
||||
paletteFooter={(
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>Claude mode</span>
|
||||
<span>{aiAgentLabel} mode</span>
|
||||
<span>↵ send</span>
|
||||
<span>esc close</span>
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,8 @@ describe('CommitDialog', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function getCommitButton() {
|
||||
// "Commit & Push" appears in both dialog title and button — use role to disambiguate
|
||||
return screen.getByRole('button', { name: 'Commit & Push' })
|
||||
function getActionButton(name = 'Commit & Push') {
|
||||
return screen.getByRole('button', { name })
|
||||
}
|
||||
|
||||
it('shows file count badge', () => {
|
||||
@@ -27,21 +26,21 @@ describe('CommitDialog', () => {
|
||||
|
||||
it('disables Commit button when message is empty', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
|
||||
expect(getCommitButton()).toBeDisabled()
|
||||
expect(getActionButton()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables Commit button when message is typed', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
|
||||
const textarea = screen.getByPlaceholderText('Commit message...')
|
||||
fireEvent.change(textarea, { target: { value: 'fix: bug fix' } })
|
||||
expect(getCommitButton()).not.toBeDisabled()
|
||||
expect(getActionButton()).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls onCommit with trimmed message on button click', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
|
||||
const textarea = screen.getByPlaceholderText('Commit message...')
|
||||
fireEvent.change(textarea, { target: { value: ' fix: bug fix ' } })
|
||||
fireEvent.click(getCommitButton())
|
||||
fireEvent.click(getActionButton())
|
||||
expect(onCommit).toHaveBeenCalledWith('fix: bug fix')
|
||||
})
|
||||
|
||||
@@ -70,7 +69,7 @@ describe('CommitDialog', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
|
||||
const textarea = screen.getByPlaceholderText('Commit message...')
|
||||
fireEvent.change(textarea, { target: { value: ' ' } })
|
||||
fireEvent.click(getCommitButton())
|
||||
fireEvent.click(getActionButton())
|
||||
expect(onCommit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -87,7 +86,7 @@ describe('CommitDialog', () => {
|
||||
|
||||
it('enables Commit button when suggestedMessage is provided', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
|
||||
expect(getCommitButton()).not.toBeDisabled()
|
||||
expect(getActionButton()).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('submits suggestedMessage on Cmd+Enter without user edits', () => {
|
||||
@@ -101,7 +100,16 @@ describe('CommitDialog', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
|
||||
const textarea = screen.getByPlaceholderText('Commit message...')
|
||||
fireEvent.change(textarea, { target: { value: 'fix: corrected typo in alpha' } })
|
||||
fireEvent.click(getCommitButton())
|
||||
fireEvent.click(getActionButton())
|
||||
expect(onCommit).toHaveBeenCalledWith('fix: corrected typo in alpha')
|
||||
})
|
||||
|
||||
it('switches to local-only copy when commitMode is local', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={2} commitMode="local" onCommit={onCommit} onClose={onClose} />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Commit' })).toBeInTheDocument()
|
||||
expect(screen.getByText('This vault has no git remote configured. Tolaria will create a local commit only.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cmd+Enter to commit locally')).toBeInTheDocument()
|
||||
expect(getActionButton('Commit')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,18 +2,66 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { CommitMode } from '../hooks/useCommitFlow'
|
||||
|
||||
type CommitDialogCopy = {
|
||||
title: string
|
||||
description: string
|
||||
actionLabel: string
|
||||
shortcutHint: string
|
||||
}
|
||||
|
||||
function getDialogCopy(commitMode: CommitMode): CommitDialogCopy {
|
||||
if (commitMode === 'local') {
|
||||
return {
|
||||
title: 'Commit',
|
||||
description: 'This vault has no git remote configured. Tolaria will create a local commit only.',
|
||||
actionLabel: 'Commit',
|
||||
shortcutHint: 'Cmd+Enter to commit locally',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Commit & Push',
|
||||
description: 'Review changed files and enter a commit message before committing and pushing.',
|
||||
actionLabel: 'Commit & Push',
|
||||
shortcutHint: 'Cmd+Enter to commit',
|
||||
}
|
||||
}
|
||||
|
||||
function changedFilesLabel(modifiedCount: number): string {
|
||||
return `${modifiedCount} file${modifiedCount !== 1 ? 's' : ''} changed`
|
||||
}
|
||||
|
||||
function isSubmitShortcut(event: React.KeyboardEvent): boolean {
|
||||
return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
|
||||
}
|
||||
|
||||
function isCloseShortcut(event: React.KeyboardEvent): boolean {
|
||||
return event.key === 'Escape'
|
||||
}
|
||||
|
||||
interface CommitDialogProps {
|
||||
open: boolean
|
||||
modifiedCount: number
|
||||
commitMode?: CommitMode
|
||||
suggestedMessage?: string
|
||||
onCommit: (message: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit, onClose }: CommitDialogProps) {
|
||||
export function CommitDialog({
|
||||
open,
|
||||
modifiedCount,
|
||||
commitMode = 'push',
|
||||
suggestedMessage,
|
||||
onCommit,
|
||||
onClose,
|
||||
}: CommitDialogProps) {
|
||||
const [message, setMessage] = useState('')
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const copy = getDialogCopy(commitMode)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -29,10 +77,10 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
if (isSubmitShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
} else if (isCloseShortcut(e)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
@@ -42,18 +90,16 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Commit & Push</DialogTitle>
|
||||
<DialogTitle>{copy.title}</DialogTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{modifiedCount} file{modifiedCount !== 1 ? 's' : ''} changed
|
||||
{changedFilesLabel(modifiedCount)}
|
||||
</Badge>
|
||||
</div>
|
||||
<DialogDescription className="sr-only">
|
||||
Review changed files and enter a commit message before committing and pushing.
|
||||
</DialogDescription>
|
||||
<DialogDescription>{copy.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<textarea
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
className="w-full resize-y rounded-lg border border-input bg-[var(--bg-input)] px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-ring"
|
||||
className="min-h-[84px] resize-y bg-[var(--bg-input)] py-2.5 text-[13px]"
|
||||
placeholder="Commit message..."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
@@ -61,13 +107,13 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
|
||||
rows={3}
|
||||
/>
|
||||
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
|
||||
<span className="text-[11px] text-muted-foreground">Cmd+Enter to commit</span>
|
||||
<span className="text-[11px] text-muted-foreground">{copy.shortcutHint}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!message.trim()}>
|
||||
Commit & Push
|
||||
{copy.actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
||||
20
src/components/DeleteProgressNotice.test.tsx
Normal file
20
src/components/DeleteProgressNotice.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { DeleteProgressNotice } from './DeleteProgressNotice'
|
||||
|
||||
describe('DeleteProgressNotice', () => {
|
||||
it('renders nothing when no delete is in progress', () => {
|
||||
render(<DeleteProgressNotice count={0} />)
|
||||
expect(screen.queryByTestId('delete-progress-notice')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the singular progress message', () => {
|
||||
render(<DeleteProgressNotice count={1} />)
|
||||
expect(screen.getByTestId('delete-progress-notice')).toHaveTextContent('Deleting note...')
|
||||
})
|
||||
|
||||
it('renders the plural progress message', () => {
|
||||
render(<DeleteProgressNotice count={3} />)
|
||||
expect(screen.getByTestId('delete-progress-notice')).toHaveTextContent('Deleting 3 notes...')
|
||||
})
|
||||
})
|
||||
31
src/components/DeleteProgressNotice.tsx
Normal file
31
src/components/DeleteProgressNotice.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { memo } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DeleteProgressNoticeProps {
|
||||
count: number
|
||||
}
|
||||
|
||||
function describeNotes(count: number): string {
|
||||
return count === 1 ? 'note' : `${count} notes`
|
||||
}
|
||||
|
||||
export const DeleteProgressNotice = memo(function DeleteProgressNotice({
|
||||
count,
|
||||
}: DeleteProgressNoticeProps) {
|
||||
if (count <= 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-20 left-1/2 z-[1095] flex -translate-x-1/2 items-center gap-2 rounded-lg',
|
||||
'border border-[var(--border)] bg-[var(--bg-dialog)] px-4 py-2 text-[13px] text-foreground',
|
||||
'shadow-[0_4px_16px_var(--shadow-dialog)] animate-in slide-in-from-bottom-2 fade-in duration-200',
|
||||
)}
|
||||
data-testid="delete-progress-notice"
|
||||
>
|
||||
<Loader2 size={14} className="animate-spin text-muted-foreground" />
|
||||
<span>{`Deleting ${describeNotes(count)}...`}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
127
src/components/DynamicPropertiesPanel.systemMetadata.test.tsx
Normal file
127
src/components/DynamicPropertiesPanel.systemMetadata.test.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
||||
import { FOCUS_NOTE_ICON_PROPERTY_EVENT } from './noteIconPropertyEvents'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
beforeAll(() => {
|
||||
global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
Element.prototype.hasPointerCapture = () => false
|
||||
Element.prototype.setPointerCapture = vi.fn()
|
||||
Element.prototype.releasePointerCapture = vi.fn()
|
||||
if (!window.getComputedStyle) window.getComputedStyle = vi.fn().mockReturnValue({}) as never
|
||||
})
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note.md',
|
||||
filename: 'note.md',
|
||||
title: 'Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 0,
|
||||
createdAt: 0,
|
||||
fileSize: 0,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
function hasSuggestedSlot(label: string): boolean {
|
||||
return screen
|
||||
.queryAllByTestId('suggested-property')
|
||||
.some((node) => node.textContent?.includes(label))
|
||||
}
|
||||
|
||||
describe('DynamicPropertiesPanel system metadata', () => {
|
||||
const onAddProperty = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('hides underscored and legacy system metadata while keeping user properties visible', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{
|
||||
_list_properties_display: ['Owner'],
|
||||
_icon: 'rocket',
|
||||
icon: 'legacy',
|
||||
order: 4,
|
||||
sort: 'title:asc',
|
||||
'_sidebar_label': 'Projects',
|
||||
Owner: 'Luca',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument()
|
||||
expect(screen.getByText('Luca')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Order')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Sort')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Sidebar label')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('treats _icon as satisfying the suggested icon slot', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ _icon: 'rocket' }}
|
||||
onAddProperty={onAddProperty}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(hasSuggestedSlot('Icon')).toBe(false)
|
||||
})
|
||||
|
||||
it('opens the icon editor without writing metadata until the user saves', async () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
onAddProperty={onAddProperty}
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent(FOCUS_NOTE_ICON_PROPERTY_EVENT))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('icon-editable-input')).toBeInTheDocument()
|
||||
})
|
||||
expect(onAddProperty).not.toHaveBeenCalled()
|
||||
|
||||
const input = screen.getByTestId('icon-editable-input')
|
||||
fireEvent.change(input, { target: { value: 'rocket' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onAddProperty).toHaveBeenCalledWith('_icon', 'rocket')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,26 @@
|
||||
import { Plus } from '@phosphor-icons/react'
|
||||
import { useMemo, useCallback, useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
import { usePropertyPanelState } from '../hooks/usePropertyPanelState'
|
||||
import { getEffectiveDisplayMode, detectPropertyType } from '../utils/propertyTypes'
|
||||
import { getEffectiveDisplayMode, detectPropertyType, DISPLAY_MODE_ICONS } from '../utils/propertyTypes'
|
||||
import { SmartPropertyValueCell, DisplayModeSelector } from './PropertyValueCells'
|
||||
import { TypeSelector } from './TypeSelector'
|
||||
import { AddPropertyForm } from './AddPropertyForm'
|
||||
import type { PropertyDisplayMode } from '../utils/propertyTypes'
|
||||
import { FOCUS_NOTE_ICON_PROPERTY_EVENT } from './noteIconPropertyEvents'
|
||||
|
||||
function toSentenceCase(key: string): string {
|
||||
const spaced = key.replace(/[_-]/g, ' ')
|
||||
if (!spaced) return spaced
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
|
||||
}
|
||||
import {
|
||||
PROPERTY_PANEL_GRID_STYLE,
|
||||
PROPERTY_PANEL_INTERACTIVE_ROW_CLASS_NAME,
|
||||
PROPERTY_PANEL_LABEL_CLASS_NAME,
|
||||
PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME,
|
||||
PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME,
|
||||
PROPERTY_PANEL_ROW_STYLE,
|
||||
} from './propertyPanelLayout'
|
||||
import { humanizePropertyKey } from '../utils/propertyLabels'
|
||||
import { canonicalSystemMetadataKey, hasSystemMetadataKey } from '../utils/systemMetadata'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
|
||||
export function containsWikilinks(value: FrontmatterValue): boolean {
|
||||
@@ -23,6 +29,8 @@ export function containsWikilinks(value: FrontmatterValue): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
const PROPERTY_ROW_CLASS_NAME = 'group/prop grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary'
|
||||
|
||||
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
|
||||
propKey: string; value: FrontmatterValue; editingKey: string | null
|
||||
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
|
||||
@@ -33,6 +41,9 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
|
||||
}) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.target !== e.currentTarget) {
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' && editingKey !== propKey) {
|
||||
e.preventDefault()
|
||||
onStartEdit(propKey)
|
||||
@@ -40,13 +51,13 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/prop grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
|
||||
<span className="flex min-w-0 items-center gap-1 text-[12px] text-muted-foreground">
|
||||
<span className="truncate">{toSentenceCase(propKey)}</span>
|
||||
<div className={PROPERTY_ROW_CLASS_NAME} style={PROPERTY_PANEL_ROW_STYLE} tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
|
||||
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME}>
|
||||
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
|
||||
<span className="min-w-0 flex-1 truncate">{humanizePropertyKey(propKey)}</span>
|
||||
{onDelete && (
|
||||
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">×</button>
|
||||
)}
|
||||
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
|
||||
@@ -57,13 +68,27 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
|
||||
function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) {
|
||||
return (
|
||||
<button
|
||||
className="mt-1 flex w-full cursor-pointer items-center gap-1 border-none bg-transparent px-1.5 text-[12px] text-muted-foreground opacity-50 transition-opacity hover:opacity-100 disabled:cursor-not-allowed"
|
||||
onClick={onClick} disabled={disabled}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={PROPERTY_PANEL_INTERACTIVE_ROW_CLASS_NAME}
|
||||
style={PROPERTY_PANEL_ROW_STYLE}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
data-testid="add-property-row"
|
||||
>
|
||||
<span className="text-[12px] leading-none">+</span>
|
||||
Add property
|
||||
</button>
|
||||
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME}>
|
||||
<span
|
||||
className={PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME}
|
||||
data-testid="add-property-icon-slot"
|
||||
>
|
||||
<Plus className="size-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 truncate">Add property</span>
|
||||
</span>
|
||||
<span aria-hidden="true" className={PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,21 +110,116 @@ function getSuggestedDisplayMode(key: string): PropertyDisplayMode {
|
||||
return SUGGESTED_PROPERTY_MODES[key] ?? 'text'
|
||||
}
|
||||
|
||||
function SuggestedPropertySlot({ label, onAdd }: { label: string; onAdd: () => void }) {
|
||||
function SuggestedPropertySlot({ label, displayMode, onAdd }: {
|
||||
label: string
|
||||
displayMode: PropertyDisplayMode
|
||||
onAdd: () => void
|
||||
}) {
|
||||
const SuggestedIcon = DISPLAY_MODE_ICONS[displayMode]
|
||||
|
||||
return (
|
||||
<button
|
||||
className="grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded border-none bg-transparent px-1.5 text-left outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary cursor-pointer"
|
||||
tabIndex={0}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={PROPERTY_PANEL_INTERACTIVE_ROW_CLASS_NAME}
|
||||
style={PROPERTY_PANEL_ROW_STYLE}
|
||||
onClick={onAdd}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onAdd() } }}
|
||||
data-testid="suggested-property"
|
||||
>
|
||||
<span className="min-w-0 truncate text-[12px] text-muted-foreground/50">{label}</span>
|
||||
<span className="min-w-0 truncate text-[12px] text-muted-foreground/30">{'\u2014'}</span>
|
||||
</button>
|
||||
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME}>
|
||||
<span
|
||||
className={PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME}
|
||||
data-testid="suggested-property-icon-slot"
|
||||
>
|
||||
<SuggestedIcon
|
||||
className="size-3.5 shrink-0 text-muted-foreground/40"
|
||||
data-testid={`suggested-property-icon-${displayMode}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
</span>
|
||||
<span className={PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME}>{'\u2014'}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function getExistingPropertyKeys(propertyEntries: [string, FrontmatterValue][], frontmatter: ParsedFrontmatter): Set<string> {
|
||||
const keys = new Set(propertyEntries.map(([key]) => key.toLowerCase()))
|
||||
for (const key of Object.keys(frontmatter)) keys.add(key.toLowerCase())
|
||||
if (hasSystemMetadataKey(keys, '_icon')) keys.add('icon')
|
||||
return keys
|
||||
}
|
||||
|
||||
function getMissingSuggestedProperties(canAddProperty: boolean, existingKeys: Set<string>, pendingSuggestedKey: string | null) {
|
||||
if (!canAddProperty) return []
|
||||
|
||||
return SUGGESTED_PROPERTIES.filter(
|
||||
({ key }) => !existingKeys.has(key.toLowerCase()) && key !== pendingSuggestedKey,
|
||||
)
|
||||
}
|
||||
|
||||
function useFocusNoteIconProperty({
|
||||
onAddProperty,
|
||||
setEditingKey,
|
||||
setPendingSuggestedKey,
|
||||
}: {
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
setEditingKey: (key: string | null) => void
|
||||
setPendingSuggestedKey: (key: string | null) => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handleFocusNoteIcon = () => {
|
||||
if (!onAddProperty) return
|
||||
setPendingSuggestedKey('icon')
|
||||
setEditingKey('icon')
|
||||
}
|
||||
|
||||
window.addEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handleFocusNoteIcon)
|
||||
return () => window.removeEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handleFocusNoteIcon)
|
||||
}, [onAddProperty, setEditingKey, setPendingSuggestedKey])
|
||||
}
|
||||
|
||||
function useSuggestedPropertyActions({
|
||||
onAddProperty,
|
||||
setEditingKey,
|
||||
setPendingSuggestedKey,
|
||||
}: {
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
setEditingKey: (key: string | null) => void
|
||||
setPendingSuggestedKey: (key: string | null) => void
|
||||
}) {
|
||||
const handleSuggestedAdd = useCallback((key: string) => {
|
||||
if (!onAddProperty) return
|
||||
setPendingSuggestedKey(key)
|
||||
setEditingKey(key)
|
||||
}, [onAddProperty, setEditingKey, setPendingSuggestedKey])
|
||||
|
||||
const handlePendingSuggestedEdit = useCallback((key: string | null) => {
|
||||
setEditingKey(key)
|
||||
if (key === null) setPendingSuggestedKey(null)
|
||||
}, [setEditingKey, setPendingSuggestedKey])
|
||||
|
||||
const handleSaveSuggestedValue = useCallback((key: string, newValue: string) => {
|
||||
setEditingKey(null)
|
||||
setPendingSuggestedKey(null)
|
||||
if (!onAddProperty) {
|
||||
return
|
||||
}
|
||||
const trimmed = newValue.trim()
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
onAddProperty(key === 'icon' ? canonicalSystemMetadataKey(key) : key, trimmed)
|
||||
}, [onAddProperty, setEditingKey, setPendingSuggestedKey])
|
||||
|
||||
return {
|
||||
handlePendingSuggestedEdit,
|
||||
handleSaveSuggestedValue,
|
||||
handleSuggestedAdd,
|
||||
}
|
||||
}
|
||||
|
||||
export function DynamicPropertiesPanel({
|
||||
entry, frontmatter, entries,
|
||||
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
|
||||
@@ -120,62 +240,26 @@ export function DynamicPropertiesPanel({
|
||||
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
|
||||
const [pendingSuggestedKey, setPendingSuggestedKey] = useState<string | null>(null)
|
||||
|
||||
const existingKeys = useMemo(() => {
|
||||
const keys = new Set(propertyEntries.map(([k]) => k.toLowerCase()))
|
||||
// Also check full frontmatter for relationship keys that are filtered out of propertyEntries
|
||||
for (const k of Object.keys(frontmatter)) keys.add(k.toLowerCase())
|
||||
return keys
|
||||
}, [propertyEntries, frontmatter])
|
||||
const existingKeys = useMemo(() => getExistingPropertyKeys(propertyEntries, frontmatter), [propertyEntries, frontmatter])
|
||||
const missingSuggested = useMemo(
|
||||
() => getMissingSuggestedProperties(Boolean(onAddProperty), existingKeys, pendingSuggestedKey),
|
||||
[existingKeys, onAddProperty, pendingSuggestedKey],
|
||||
)
|
||||
const {
|
||||
handlePendingSuggestedEdit,
|
||||
handleSaveSuggestedValue,
|
||||
handleSuggestedAdd,
|
||||
} = useSuggestedPropertyActions({
|
||||
onAddProperty,
|
||||
setEditingKey,
|
||||
setPendingSuggestedKey,
|
||||
})
|
||||
|
||||
const missingSuggested = onAddProperty
|
||||
? SUGGESTED_PROPERTIES.filter(
|
||||
p => !existingKeys.has(p.key.toLowerCase()) && p.key !== pendingSuggestedKey,
|
||||
)
|
||||
: []
|
||||
|
||||
const handleSuggestedAdd = useCallback((key: string) => {
|
||||
if (!onAddProperty) return
|
||||
setPendingSuggestedKey(key)
|
||||
setEditingKey(key)
|
||||
}, [onAddProperty, setEditingKey])
|
||||
|
||||
const handlePendingSuggestedEdit = useCallback((key: string | null) => {
|
||||
setEditingKey(key)
|
||||
if (key === null) setPendingSuggestedKey(null)
|
||||
}, [setEditingKey])
|
||||
|
||||
const handleSaveSuggestedValue = useCallback((key: string, newValue: string) => {
|
||||
setEditingKey(null)
|
||||
setPendingSuggestedKey(null)
|
||||
if (!onAddProperty) {
|
||||
return
|
||||
}
|
||||
const trimmed = newValue.trim()
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
onAddProperty(key, trimmed)
|
||||
}, [onAddProperty, setEditingKey])
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocusNoteIcon = () => {
|
||||
const existingIconKey = propertyEntries.find(([key]) => key.toLowerCase() === 'icon')?.[0]
|
||||
|
||||
if (!existingIconKey) {
|
||||
if (!onAddProperty) return
|
||||
onAddProperty('icon', '')
|
||||
}
|
||||
|
||||
setEditingKey(existingIconKey ?? 'icon')
|
||||
}
|
||||
|
||||
window.addEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handleFocusNoteIcon)
|
||||
return () => window.removeEventListener(FOCUS_NOTE_ICON_PROPERTY_EVENT, handleFocusNoteIcon)
|
||||
}, [onAddProperty, propertyEntries, setEditingKey])
|
||||
useFocusNoteIconProperty({ onAddProperty, setEditingKey, setPendingSuggestedKey })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="grid min-w-0 gap-x-2 gap-y-1.5" style={PROPERTY_PANEL_GRID_STYLE}>
|
||||
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<PropertyRow
|
||||
@@ -208,13 +292,27 @@ export function DynamicPropertiesPanel({
|
||||
/>
|
||||
)}
|
||||
{missingSuggested.map(({ key, label }) => (
|
||||
<SuggestedPropertySlot key={key} label={label} onAdd={() => handleSuggestedAdd(key)} />
|
||||
<SuggestedPropertySlot
|
||||
key={key}
|
||||
label={label}
|
||||
displayMode={getSuggestedDisplayMode(key)}
|
||||
onAdd={() => handleSuggestedAdd(key)}
|
||||
/>
|
||||
))}
|
||||
{!showAddDialog && (
|
||||
<AddPropertyButton
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
disabled={!onAddProperty}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showAddDialog
|
||||
? <AddPropertyForm onAdd={handleAdd} onCancel={() => setShowAddDialog(false)} vaultStatuses={vaultStatuses} />
|
||||
: <AddPropertyButton onClick={() => setShowAddDialog(true)} disabled={!onAddProperty} />
|
||||
}
|
||||
{showAddDialog && (
|
||||
<AddPropertyForm
|
||||
onAdd={handleAdd}
|
||||
onCancel={() => setShowAddDialog(false)}
|
||||
vaultStatuses={vaultStatuses}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { normalizeUrl, openExternalUrl } from '../utils/url'
|
||||
import { getTagStyle } from '../utils/tagStyles'
|
||||
import { PROPERTY_CHIP_STYLE } from './propertyChipStyles'
|
||||
|
||||
export function UrlValue({
|
||||
value,
|
||||
@@ -125,7 +126,7 @@ export function EditableValue({
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-6 w-full min-w-0 cursor-pointer items-center justify-start overflow-hidden rounded-md px-2 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
className="inline-flex h-6 w-full min-w-0 cursor-pointer items-center justify-start overflow-hidden rounded-md px-0 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onStartEdit}
|
||||
title={value || 'Click to edit'}
|
||||
>
|
||||
@@ -212,13 +213,11 @@ export function TagPillList({
|
||||
) : (
|
||||
<span
|
||||
key={idx}
|
||||
className="group/pill relative inline-flex h-6 max-w-full min-w-0 cursor-pointer items-center overflow-hidden rounded-md transition-colors"
|
||||
className="group/pill relative inline-flex max-w-full min-w-0 cursor-pointer items-center overflow-hidden transition-colors"
|
||||
style={{
|
||||
...getTagStyle(item),
|
||||
...PROPERTY_CHIP_STYLE,
|
||||
backgroundColor: getTagStyle(item).bg,
|
||||
padding: '0 8px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: getTagStyle(item).color,
|
||||
}}
|
||||
onClick={() => handleStartEdit(idx)}
|
||||
title="Click to edit"
|
||||
@@ -255,8 +254,8 @@ export function TagPillList({
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="inline-flex h-6 items-center justify-center rounded-md border-none bg-muted px-2 text-[12px] font-medium leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
style={{ padding: '0 8px' }}
|
||||
className="inline-flex items-center justify-center border-none bg-muted leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
style={PROPERTY_CHIP_STYLE}
|
||||
onClick={() => setIsAddingNew(true)}
|
||||
title={`Add ${label.toLowerCase()}`}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import type { ComponentProps, PropsWithChildren } from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: '',
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
// Hoisted mock editor — available before vi.mock factory runs.
|
||||
// Tests can reconfigure spies (e.g. mockTryParse.mockResolvedValue) before rendering.
|
||||
const mockEditor = vi.hoisted(() => ({
|
||||
@@ -12,6 +27,7 @@ const mockEditor = vi.hoisted(() => ({
|
||||
onMount: vi.fn((cb: () => void) => { cb(); return () => {} }),
|
||||
prosemirrorView: {} as Record<string, unknown>,
|
||||
blocksToHTMLLossy: vi.fn(() => ''),
|
||||
blocksToMarkdownLossy: vi.fn(() => '# Test Project\n\nThis is a test note with some words to count.\n'),
|
||||
_tiptapEditor: { commands: { setContent: vi.fn() } },
|
||||
focus: vi.fn(),
|
||||
setTextCursorPosition: vi.fn(),
|
||||
@@ -19,11 +35,16 @@ const mockEditor = vi.hoisted(() => ({
|
||||
|
||||
// Mock BlockNote components
|
||||
vi.mock('@blocknote/core', () => ({
|
||||
BlockNoteSchema: { create: () => ({}) },
|
||||
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
|
||||
createCodeBlockSpec: vi.fn(() => ({})),
|
||||
defaultInlineContentSpecs: {},
|
||||
filterSuggestionItems: vi.fn(() => []),
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/code-block', () => ({
|
||||
codeBlockOptions: {},
|
||||
}))
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
|
||||
const mockFilterSuggestionItems = vi.fn((...args: any[]) => args[0] ?? [])
|
||||
vi.mock('@blocknote/core/extensions', () => ({
|
||||
@@ -37,6 +58,18 @@ let capturedGetItems: ((query: string) => Promise<any[]>) | null = null
|
||||
vi.mock('@blocknote/react', () => ({
|
||||
createReactInlineContentSpec: () => ({ render: () => null }),
|
||||
useCreateBlockNote: () => mockEditor,
|
||||
FormattingToolbar: ({ children }: PropsWithChildren) => <>{children}</>,
|
||||
getFormattingToolbarItems: () => [],
|
||||
getDefaultReactSlashMenuItems: () => [],
|
||||
ComponentsContext: {
|
||||
Provider: ({ children }: PropsWithChildren) => <>{children}</>,
|
||||
},
|
||||
BlockNoteViewRaw: ({ children, editable }: PropsWithChildren<{ editable?: boolean }>) => (
|
||||
<div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
FormattingToolbarController: () => null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
|
||||
SuggestionMenuController: (props: any) => {
|
||||
capturedGetItemsByTrigger[props.triggerCharacter] = props.getItems
|
||||
@@ -46,14 +79,22 @@ vi.mock('@blocknote/react', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/mantine', () => ({
|
||||
BlockNoteView: ({ children, editable }: { children?: React.ReactNode; editable?: boolean }) => <div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>{children}</div>,
|
||||
components: {},
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/mantine/style.css', () => ({}))
|
||||
|
||||
vi.mock('./tolariaEditorFormatting', () => ({
|
||||
TolariaFormattingToolbar: ({ children }: PropsWithChildren) => <>{children}</>,
|
||||
TolariaFormattingToolbarController: () => null,
|
||||
}))
|
||||
|
||||
import { Editor } from './Editor'
|
||||
import { applyPendingRawExitContent } from './editorRawModeSync'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { bindVaultConfigStore, resetVaultConfigStore } from '../utils/vaultConfigStore'
|
||||
|
||||
type EditorComponentProps = ComponentProps<typeof Editor>
|
||||
|
||||
const mockEntry: VaultEntry = {
|
||||
path: '/vault/project/test.md',
|
||||
@@ -122,74 +163,49 @@ const defaultProps = {
|
||||
onCreateNote: vi.fn(),
|
||||
}
|
||||
|
||||
function renderEditor(overrides: Partial<EditorComponentProps> = {}) {
|
||||
return render(<Editor {...defaultProps} {...overrides} />)
|
||||
}
|
||||
|
||||
describe('Editor', () => {
|
||||
it('shows empty state when no tabs are open', () => {
|
||||
render(<Editor {...defaultProps} />)
|
||||
renderEditor()
|
||||
expect(screen.getByText('Select a note to start editing')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Cmd\+P to search/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders tab bar with open tabs', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[mockTab]}
|
||||
activeTabPath={mockEntry.path}
|
||||
/>
|
||||
)
|
||||
// Tab bar renders when a tab is active
|
||||
it('renders an invisible drag region in the empty state', () => {
|
||||
const { container } = renderEditor()
|
||||
const dragRegion = container.querySelector('[data-testid="editor-empty-state-drag-region"]')
|
||||
|
||||
expect(dragRegion).toHaveAttribute('data-tauri-drag-region')
|
||||
expect(dragRegion).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['renders tab bar with open tabs', {}],
|
||||
['shows BlockNote editor when a tab is active', {}],
|
||||
['renders editor for modified file without breadcrumb status', { getNoteStatus: () => 'modified' as const }],
|
||||
['renders editor for new file without breadcrumb status', { getNoteStatus: () => 'new' as const }],
|
||||
])('%s', (_label, overrides) => {
|
||||
renderEditor({
|
||||
tabs: [mockTab],
|
||||
activeTabPath: mockEntry.path,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders breadcrumb bar with action buttons', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[mockTab]}
|
||||
activeTabPath={mockEntry.path}
|
||||
/>
|
||||
)
|
||||
// Breadcrumb bar shows action buttons (e.g. search, archive)
|
||||
renderEditor({
|
||||
tabs: [mockTab],
|
||||
activeTabPath: mockEntry.path,
|
||||
})
|
||||
|
||||
expect(screen.getByTitle('Search in file')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows BlockNote editor when a tab is active', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[mockTab]}
|
||||
activeTabPath={mockEntry.path}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders editor for modified file without breadcrumb status', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[mockTab]}
|
||||
activeTabPath={mockEntry.path}
|
||||
getNoteStatus={() => 'modified'}
|
||||
/>
|
||||
)
|
||||
// Editor still renders; breadcrumb bar no longer shows status indicators
|
||||
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders editor for new file without breadcrumb status', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[mockTab]}
|
||||
activeTabPath={mockEntry.path}
|
||||
getNoteStatus={() => 'new'}
|
||||
/>
|
||||
)
|
||||
// Editor still renders; breadcrumb bar no longer shows status indicators
|
||||
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the legacy title field for untitled draft notes', () => {
|
||||
const draftEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
@@ -276,6 +292,101 @@ describe('Editor', () => {
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
mockEditor.insertBlocks.mockClear()
|
||||
})
|
||||
|
||||
it('does not apply note A raw content to note B when raw mode closes during note B load', async () => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore(
|
||||
{
|
||||
zoom: null,
|
||||
view_mode: null,
|
||||
editor_mode: null,
|
||||
tag_colors: null,
|
||||
status_colors: null,
|
||||
property_display_modes: null,
|
||||
inbox: null,
|
||||
},
|
||||
vi.fn(),
|
||||
)
|
||||
|
||||
const rawToggleRef = { current: (() => {}) as () => void }
|
||||
const onContentChange = vi.fn()
|
||||
const noteA = {
|
||||
entry: {
|
||||
...mockEntry,
|
||||
path: '/vault/project/note-a.md',
|
||||
filename: 'note-a.md',
|
||||
title: 'Note A',
|
||||
},
|
||||
content: '---\ntitle: Note A\n---\n\n# Note A\n\nAlpha body.',
|
||||
}
|
||||
const noteBEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
path: '/vault/project/note-b.md',
|
||||
filename: 'note-b.md',
|
||||
title: 'Note B',
|
||||
}
|
||||
const noteB = {
|
||||
entry: noteBEntry,
|
||||
content: '---\ntitle: Note B\n---\n\n# Note B\n\nBravo body.',
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[noteA]}
|
||||
activeTabPath={noteA.entry.path}
|
||||
entries={[noteA.entry, noteBEntry]}
|
||||
onContentChange={onContentChange}
|
||||
rawToggleRef={rawToggleRef}
|
||||
/>,
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(typeof rawToggleRef.current).toBe('function')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await rawToggleRef.current()
|
||||
})
|
||||
onContentChange.mockClear()
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
|
||||
rerender(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[noteA]}
|
||||
activeTabPath={noteB.entry.path}
|
||||
entries={[noteA.entry, noteBEntry]}
|
||||
onContentChange={onContentChange}
|
||||
rawToggleRef={rawToggleRef}
|
||||
/>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await rawToggleRef.current()
|
||||
})
|
||||
|
||||
expect(onContentChange).not.toHaveBeenCalledWith(noteB.entry.path, noteA.content)
|
||||
|
||||
rerender(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[noteA, noteB]}
|
||||
activeTabPath={noteB.entry.path}
|
||||
entries={[noteA.entry, noteB.entry]}
|
||||
onContentChange={onContentChange}
|
||||
rawToggleRef={rawToggleRef}
|
||||
/>,
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(expect.stringContaining('Note B'))
|
||||
})
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalledWith(expect.stringContaining('Note A'))
|
||||
|
||||
resetVaultConfigStore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPendingRawExitContent', () => {
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
import { useRef, useEffect, useCallback, useState, memo } from 'react'
|
||||
import { useRef, useEffect, useCallback, memo } from 'react'
|
||||
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
|
||||
import { useCreateBlockNote } from '@blocknote/react'
|
||||
import '@blocknote/mantine/style.css'
|
||||
import { uploadImageFile } from '../hooks/useImageDrop'
|
||||
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
|
||||
import type { VaultEntry, GitCommit, NoteStatus } from '../types'
|
||||
import type { NoteListItem } from '../utils/ai-context'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import { ResizeHandle } from './ResizeHandle'
|
||||
import { useDiffMode } from '../hooks/useDiffMode'
|
||||
import { useRawMode } from '../hooks/useRawMode'
|
||||
import { useDiffMode, type CommitDiffRequest } from '../hooks/useDiffMode'
|
||||
import { useEditorFocus } from '../hooks/useEditorFocus'
|
||||
import { useDragRegion } from '../hooks/useDragRegion'
|
||||
import { EditorRightPanel } from './EditorRightPanel'
|
||||
import { EditorContent } from './EditorContent'
|
||||
import { schema } from './editorSchema'
|
||||
import { clearTableResizeState } from './tableResizeState'
|
||||
import {
|
||||
type PendingRawExitContent,
|
||||
applyPendingRawExitContent,
|
||||
buildPendingRawExitContent,
|
||||
rememberPendingRawExitContent,
|
||||
resolvePendingRawExitContent,
|
||||
resolveRawModeContent,
|
||||
syncActiveTabIntoRawBuffer,
|
||||
} from './editorRawModeSync'
|
||||
import { useRawModeWithFlush } from './useRawModeWithFlush'
|
||||
import './Editor.css'
|
||||
import './EditorTheme.css'
|
||||
|
||||
@@ -38,11 +35,15 @@ interface EditorProps {
|
||||
onNavigateWikilink: (target: string) => void
|
||||
onLoadDiff?: (path: string) => Promise<string>
|
||||
onLoadDiffAtCommit?: (path: string, commitHash: string) => Promise<string>
|
||||
pendingCommitDiffRequest?: CommitDiffRequest | null
|
||||
onPendingCommitDiffHandled?: (requestId: number) => void
|
||||
getNoteStatus?: (path: string) => NoteStatus
|
||||
onCreateNote?: () => void
|
||||
inspectorCollapsed: boolean
|
||||
onToggleInspector: () => void
|
||||
inspectorWidth: number
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiAgentReady?: boolean
|
||||
onInspectorResize: (delta: number) => void
|
||||
inspectorEntry: VaultEntry | null
|
||||
inspectorContent: string | null
|
||||
@@ -118,10 +119,23 @@ function useEditorModeExclusion({
|
||||
}
|
||||
|
||||
function EditorEmptyState() {
|
||||
const breadcrumbBarHeight = 52
|
||||
const { onMouseDown } = useDragRegion()
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
||||
<p className="m-0 text-[15px]">Select a note to start editing</p>
|
||||
<span className="text-xs text-muted-foreground">Cmd+P to search · Cmd+N to create</span>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-tauri-drag-region
|
||||
data-testid="editor-empty-state-drag-region"
|
||||
className="shrink-0"
|
||||
onMouseDown={onMouseDown}
|
||||
style={{ height: breadcrumbBarHeight }}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
||||
<p className="m-0 text-[15px]">Select a note to start editing</p>
|
||||
<span className="text-xs text-muted-foreground">Cmd+P to search · Cmd+N to create</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -133,56 +147,16 @@ interface EditorSetupParams {
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onLoadDiff?: (path: string) => Promise<string>
|
||||
onLoadDiffAtCommit?: (path: string, commitHash: string) => Promise<string>
|
||||
pendingCommitDiffRequest?: CommitDiffRequest | null
|
||||
onPendingCommitDiffHandled?: (requestId: number) => void
|
||||
getNoteStatus?: (path: string) => NoteStatus
|
||||
rawToggleRef?: React.MutableRefObject<() => void>
|
||||
diffToggleRef?: React.MutableRefObject<() => void>
|
||||
}
|
||||
|
||||
function useRawModeWithFlush(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
activeTabPath: string | null,
|
||||
activeTabContent: string | null,
|
||||
onContentChange?: (path: string, content: string) => void,
|
||||
) {
|
||||
const rawLatestContentRef = useRef<string | null>(null)
|
||||
const [pendingRawExitContent, setPendingRawExitContent] = useState<PendingRawExitContent | null>(null)
|
||||
const [rawModeContentOverride, setRawModeContentOverride] = useState<PendingRawExitContent | null>(null)
|
||||
|
||||
const handleFlushPending = useCallback(async () => {
|
||||
const syncedContent = syncActiveTabIntoRawBuffer({
|
||||
editor,
|
||||
activeTabPath,
|
||||
activeTabContent,
|
||||
rawLatestContentRef,
|
||||
onContentChange,
|
||||
})
|
||||
setRawModeContentOverride(buildPendingRawExitContent(activeTabPath, syncedContent))
|
||||
clearTableResizeState(editor)
|
||||
return true
|
||||
}, [activeTabContent, activeTabPath, editor, onContentChange])
|
||||
|
||||
const handleBeforeRawEnd = useCallback(() => {
|
||||
setPendingRawExitContent(rememberPendingRawExitContent({
|
||||
activeTabPath,
|
||||
rawLatestContentRef,
|
||||
onContentChange,
|
||||
}))
|
||||
setRawModeContentOverride(null)
|
||||
rawLatestContentRef.current = null
|
||||
}, [activeTabPath, onContentChange])
|
||||
|
||||
const { rawMode, handleToggleRaw } = useRawMode({
|
||||
activeTabPath,
|
||||
onFlushPending: handleFlushPending,
|
||||
onBeforeRawEnd: handleBeforeRawEnd,
|
||||
})
|
||||
|
||||
return { rawMode, handleToggleRaw, rawLatestContentRef, pendingRawExitContent, setPendingRawExitContent, rawModeContentOverride }
|
||||
}
|
||||
|
||||
function useEditorSetup({
|
||||
tabs, activeTabPath, vaultPath, onContentChange,
|
||||
onLoadDiff, onLoadDiffAtCommit, getNoteStatus,
|
||||
onLoadDiff, onLoadDiffAtCommit, pendingCommitDiffRequest, onPendingCommitDiffHandled, getNoteStatus,
|
||||
rawToggleRef, diffToggleRef,
|
||||
}: EditorSetupParams) {
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
@@ -223,7 +197,11 @@ function useEditorSetup({
|
||||
useEditorFocus(editor, editorMountedRef)
|
||||
|
||||
const { diffMode, diffContent, diffLoading, handleToggleDiff, handleViewCommitDiff } = useDiffMode({
|
||||
activeTabPath, onLoadDiff, onLoadDiffAtCommit,
|
||||
activeTabPath,
|
||||
onLoadDiff,
|
||||
onLoadDiffAtCommit,
|
||||
pendingCommitDiffRequest,
|
||||
onPendingCommitDiffHandled,
|
||||
})
|
||||
|
||||
const { handleToggleDiffExclusive, handleToggleRawExclusive } = useEditorModeExclusion({
|
||||
@@ -247,7 +225,9 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
const {
|
||||
tabs, activeTabPath, entries, onNavigateWikilink,
|
||||
getNoteStatus,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
|
||||
onInspectorResize,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
@@ -267,7 +247,10 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
} = useEditorSetup({
|
||||
tabs, activeTabPath, vaultPath, onContentChange,
|
||||
onLoadDiff: props.onLoadDiff,
|
||||
onLoadDiffAtCommit: props.onLoadDiffAtCommit, getNoteStatus,
|
||||
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
|
||||
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
|
||||
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
|
||||
getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
|
||||
})
|
||||
|
||||
@@ -316,6 +299,8 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
showAIChat={showAIChat}
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
inspectorWidth={inspectorWidth}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
inspectorEntry={inspectorEntry}
|
||||
inspectorContent={inspectorContent}
|
||||
entries={entries}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import type { NoteListItem } from '../utils/ai-context'
|
||||
import { Inspector, type FrontmatterValue } from './Inspector'
|
||||
@@ -7,6 +8,8 @@ interface EditorRightPanelProps {
|
||||
showAIChat?: boolean
|
||||
inspectorCollapsed: boolean
|
||||
inspectorWidth: number
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiAgentReady?: boolean
|
||||
inspectorEntry: VaultEntry | null
|
||||
inspectorContent: string | null
|
||||
entries: VaultEntry[]
|
||||
@@ -32,6 +35,7 @@ interface EditorRightPanelProps {
|
||||
|
||||
export function EditorRightPanel({
|
||||
showAIChat, inspectorCollapsed, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
|
||||
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
|
||||
noteList, noteListFilter,
|
||||
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
|
||||
@@ -47,6 +51,8 @@ export function EditorRightPanel({
|
||||
<AiPanel
|
||||
onClose={() => onToggleAIChat?.()}
|
||||
onOpenNote={onOpenNote}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
}
|
||||
|
||||
/* --- Inline: code --- */
|
||||
.editor__blocknote-container .bn-editor code {
|
||||
.editor__blocknote-container .bn-inline-content code {
|
||||
font-family: var(--inline-styles-code-font-family);
|
||||
font-size: var(--inline-styles-code-font-size);
|
||||
background-color: var(--inline-styles-code-background-color);
|
||||
@@ -236,16 +236,15 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* --- Code blocks --- */
|
||||
.editor__blocknote-container [data-content-type="codeBlock"] {
|
||||
font-family: var(--code-blocks-font-family);
|
||||
font-size: var(--code-blocks-font-size);
|
||||
line-height: var(--code-blocks-line-height);
|
||||
background-color: var(--code-blocks-background-color);
|
||||
padding: var(--code-blocks-padding-vertical) var(--code-blocks-padding-horizontal);
|
||||
border-radius: var(--code-blocks-border-radius);
|
||||
margin-top: var(--code-blocks-margin-vertical);
|
||||
margin-bottom: var(--code-blocks-margin-vertical);
|
||||
/* Code blocks intentionally keep BlockNote's dark shell and syntax-highlighted content. */
|
||||
.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] pre code .shiki {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* --- Blockquote --- */
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { GithubLogo, CircleNotch, ArrowClockwise, Copy, Check } from '@phosphor-icons/react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
import type { DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from '../types'
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
type OAuthStatus = 'idle' | 'waiting' | 'error'
|
||||
|
||||
/** Process a device flow poll result. Returns 'done' if auth complete, 'continue' to keep polling. */
|
||||
function processPollResult(
|
||||
result: DeviceFlowPollResult,
|
||||
callbacks: {
|
||||
onComplete: (token: string) => Promise<void>
|
||||
onExpired: () => void
|
||||
onError: (msg: string) => void
|
||||
},
|
||||
): 'done' | 'continue' {
|
||||
if (result.status === 'complete' && result.access_token) {
|
||||
callbacks.onComplete(result.access_token)
|
||||
return 'done'
|
||||
}
|
||||
if (result.status === 'expired') {
|
||||
callbacks.onExpired()
|
||||
return 'done'
|
||||
}
|
||||
if (result.status === 'error') {
|
||||
callbacks.onError(result.error ?? 'Authorization failed.')
|
||||
return 'done'
|
||||
}
|
||||
return 'continue'
|
||||
}
|
||||
|
||||
interface GitHubDeviceFlowProps {
|
||||
onConnected: (token: string, username: string) => void
|
||||
}
|
||||
|
||||
export function GitHubDeviceFlow({ onConnected }: GitHubDeviceFlowProps) {
|
||||
const [oauthStatus, setOauthStatus] = useState<OAuthStatus>('idle')
|
||||
const [userCode, setUserCode] = useState<string | null>(null)
|
||||
const [verificationUri, setVerificationUri] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const pollingRef = useRef(false)
|
||||
const deviceCodeRef = useRef<string | null>(null)
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
pollingRef.current = false
|
||||
deviceCodeRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => { pollingRef.current = false }
|
||||
}, [])
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
setOauthStatus('waiting')
|
||||
setErrorMessage(null)
|
||||
setUserCode(null)
|
||||
|
||||
try {
|
||||
const flowStart = await tauriCall<DeviceFlowStart>('github_device_flow_start')
|
||||
setUserCode(flowStart.user_code)
|
||||
setVerificationUri(flowStart.verification_uri)
|
||||
deviceCodeRef.current = flowStart.device_code
|
||||
openExternalUrl(flowStart.verification_uri).catch(() => {})
|
||||
|
||||
pollingRef.current = true
|
||||
const intervalMs = Math.max(flowStart.interval * 1000, 5000)
|
||||
|
||||
const pollLoop = async () => {
|
||||
while (pollingRef.current && deviceCodeRef.current) {
|
||||
await new Promise(r => setTimeout(r, intervalMs))
|
||||
if (!pollingRef.current) break
|
||||
|
||||
const result = await tauriCall<DeviceFlowPollResult>('github_device_flow_poll', {
|
||||
deviceCode: deviceCodeRef.current,
|
||||
})
|
||||
const outcome = processPollResult(result, {
|
||||
onComplete: async (token) => {
|
||||
const user = await tauriCall<GitHubUser>('github_get_user', { token })
|
||||
stopPolling()
|
||||
setOauthStatus('idle')
|
||||
setUserCode(null)
|
||||
onConnected(token, user.login)
|
||||
},
|
||||
onExpired: () => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage('Authorization expired. Please try again.')
|
||||
},
|
||||
onError: (msg) => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(msg)
|
||||
},
|
||||
})
|
||||
if (outcome === 'done') return
|
||||
}
|
||||
}
|
||||
|
||||
pollLoop().catch(err => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Polling failed.')
|
||||
})
|
||||
} catch (err) {
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Failed to start login.')
|
||||
}
|
||||
}, [onConnected, stopPolling])
|
||||
|
||||
const resetOAuth = useCallback(() => {
|
||||
stopPolling()
|
||||
setOauthStatus('idle')
|
||||
setUserCode(null)
|
||||
setVerificationUri(null)
|
||||
setErrorMessage(null)
|
||||
}, [stopPolling])
|
||||
|
||||
if (oauthStatus === 'waiting' && userCode) {
|
||||
return <DeviceCodeView userCode={userCode} verificationUri={verificationUri} onCancel={resetOAuth} />
|
||||
}
|
||||
|
||||
return <LoginButton onLogin={handleLogin} disabled={oauthStatus === 'waiting'} errorMessage={errorMessage} onRetry={errorMessage ? () => { resetOAuth(); handleLogin() } : undefined} />
|
||||
}
|
||||
|
||||
function DeviceCodeView({ userCode, verificationUri, onCancel }: { userCode: string; verificationUri: string | null; onCancel: () => void }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopyCode = useCallback(() => {
|
||||
navigator.clipboard.writeText(userCode).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}).catch(() => {})
|
||||
}, [userCode])
|
||||
|
||||
const handleOpenUrl = useCallback(() => {
|
||||
if (verificationUri) openExternalUrl(verificationUri).catch(() => {})
|
||||
}, [verificationUri])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }} data-testid="github-waiting">
|
||||
<div
|
||||
className="border border-border rounded px-4 py-3"
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 8, textAlign: 'center' }}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>Enter this code on GitHub:</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div
|
||||
style={{ fontSize: 24, fontWeight: 700, letterSpacing: 4, color: 'var(--foreground)', fontFamily: 'monospace' }}
|
||||
data-testid="github-user-code"
|
||||
>
|
||||
{userCode}
|
||||
</div>
|
||||
<button
|
||||
className="border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={handleCopyCode}
|
||||
title="Copy code"
|
||||
data-testid="github-copy-code"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{verificationUri && (
|
||||
<button
|
||||
className="border-none bg-transparent text-muted-foreground cursor-pointer hover:text-foreground underline"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={handleOpenUrl}
|
||||
data-testid="github-open-url"
|
||||
>
|
||||
{verificationUri}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-2" style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>
|
||||
<CircleNotch size={14} className="animate-spin" />
|
||||
Waiting for authorization...
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground"
|
||||
style={{ fontSize: 12, padding: '6px 12px', alignSelf: 'center' }}
|
||||
onClick={onCancel}
|
||||
data-testid="github-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginButton({ onLogin, disabled, errorMessage, onRetry }: { onLogin: () => void; disabled: boolean; errorMessage: string | null; onRetry?: () => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button
|
||||
className="border-none rounded cursor-pointer flex items-center justify-center gap-2"
|
||||
style={{ fontSize: 13, fontWeight: 500, padding: '8px 16px', background: 'var(--foreground)', color: 'var(--background)', height: 36 }}
|
||||
onClick={onLogin}
|
||||
disabled={disabled}
|
||||
data-testid="github-login"
|
||||
>
|
||||
<GithubLogo size={16} weight="fill" />
|
||||
Login with GitHub
|
||||
</button>
|
||||
{errorMessage && (
|
||||
<div className="flex items-center gap-2" style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)' }}>
|
||||
<span data-testid="github-error">{errorMessage}</span>
|
||||
{onRetry && (
|
||||
<button
|
||||
className="border-none bg-transparent cursor-pointer hover:text-foreground flex items-center gap-1"
|
||||
style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)', padding: 0 }}
|
||||
onClick={onRetry}
|
||||
data-testid="github-retry"
|
||||
>
|
||||
<ArrowClockwise size={12} />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user