feat: auto-build, GitHub Release, and in-app updater (#14)
* ci: auto-release workflow on merge to main Rewrite .github/workflows/release.yml to trigger on every push to main instead of manual tag pushes. The workflow now: - Computes version as 0.YYYYMMDD.GITHUB_RUN_NUMBER - Builds aarch64-apple-darwin and x86_64-apple-darwin in parallel - Merges them into a universal binary using lipo - Creates a universal .dmg and signed updater tarball - Generates latest.json with per-arch and universal platform entries - Publishes a GitHub Release with auto-generated release notes - Updates a GitHub Pages release history site (gh-pages branch) Product decisions: - Universal binary approach: copy arm64 .app as base, lipo the main executable, keep everything else from arm64 (shared frameworks are architecture-independent). This is the standard Tauri pattern. - Per-arch updater tarballs are also uploaded so the Tauri updater can download the correct arch-specific build (smaller download). - Release notes are auto-generated from git log since last tag. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: github pages with release history Use peaceiris/actions-gh-pages@v4 to deploy a release history site. The page fetches releases.json (also deployed) and renders each release with date, notes, and download links for .dmg files. This handles the gh-pages branch creation automatically on first run. The page is available at https://refactoringhq.github.io/laputa-app/ Product decision: used fetch() to load releases.json at runtime instead of inlining it, which is cleaner and avoids shell escaping issues with release note content. The releases.json is deployed alongside index.html. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: in-app update notification UI Replace the old window.confirm updater with a proper React-based update notification system: - useUpdater hook now exposes state machine (idle → available → downloading → ready) and actions (startDownload, openReleaseNotes, dismiss) - UpdateBanner component renders at the top of the app shell: - "Available" state: shows version, Release Notes link, Update Now button, dismiss X - "Downloading" state: animated spinner, progress bar with percentage - "Ready" state: Restart Now button to apply the update - Silently checks on startup after 3s delay; fails silently on network errors or 404 - Release Notes link opens the GitHub Pages release history site Product decisions: - Banner at top of app (not a modal) — non-intrusive, visible but not blocking. User can dismiss and continue working. - Progress bar shows during download so user knows it's working. - Separate "Restart Now" state after download so user controls when the app restarts (they may have unsaved work). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: updater component tests Rewrite useUpdater hook tests and add UpdateBanner component tests: Hook tests (10 cases): - Starts in idle state - Does nothing when not in Tauri - Checks for updates after 3s delay - Stays idle when no update available - Transitions to available when update found - Handles missing release body gracefully - Stays idle on network error (fails silently) - Dismiss returns to idle - openReleaseNotes opens correct URL - startDownload transitions through downloading to ready Component tests (10 cases): - Renders nothing when idle - Renders nothing on error - Shows version and buttons when available - Update Now calls startDownload - Release Notes calls openReleaseNotes - Dismiss button works - Shows progress bar during download - Shows 0% at start of download - Shows restart button when ready - Restart button calls restartApp All 457 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * design: auto-build-release wireframes Copy ui-design.pen as base. Frames to be added for: 1. Update notification banner (visible state) — horizontal bar at top of app shell with version text, Release Notes link, Update Now button, and dismiss X 2. Update download progress state — spinner icon, progress bar with percentage, downloading text 3. "Restart to apply" state — green accent, version text, Restart Now button Note: Pencil editor was not available during this session. The base design file is committed; frames will be added when the editor is accessible. The implemented component (UpdateBanner.tsx) serves as the source of truth for the design. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update ARCHITECTURE.md with release/update system Add comprehensive documentation for: - Release pipeline (4-phase workflow: version → build → release → pages) - Versioning scheme (0.YYYYMMDD.RUN_NUMBER) - Universal binary strategy (lipo merge) - Updater endpoint and latest.json manifest - In-app update UI state machine - GitHub Pages release history site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: rustfmt formatting * fix: rustfmt build.rs --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
400
.github/workflows/release.yml
vendored
400
.github/workflows/release.yml
vendored
@@ -2,24 +2,50 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
APP_NAME: Laputa
|
||||
BUNDLE_ID: com.tauri.dev
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release (macOS)
|
||||
permissions:
|
||||
contents: write
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 1: Compute the version string once
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
version:
|
||||
name: Compute version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
tag: ${{ steps.ver.outputs.tag }}
|
||||
steps:
|
||||
- id: ver
|
||||
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"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 2: Build each architecture in parallel
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
needs: version
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-apple-darwin
|
||||
runner: macos-latest
|
||||
- target: x86_64-apple-darwin
|
||||
runner: macos-latest
|
||||
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
- arch: aarch64
|
||||
target: aarch64-apple-darwin
|
||||
- arch: x86_64
|
||||
target: x86_64-apple-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -39,19 +65,349 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src-tauri
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build and release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
# Stamp the computed version into tauri.conf.json and Cargo.toml
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
# tauri.conf.json
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
# Cargo.toml
|
||||
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Build Tauri app
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
pnpm tauri build --target ${{ matrix.target }}
|
||||
|
||||
# Upload the .app bundle so the merge job can lipo them together.
|
||||
# Also upload the per-arch .tar.gz updater artifact and its .sig.
|
||||
- name: Upload arch artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: 'Laputa v__VERSION__'
|
||||
releaseBody: 'See the assets below to download and install this version.'
|
||||
releaseDraft: false
|
||||
name: app-${{ matrix.arch }}
|
||||
path: |
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/${{ env.APP_NAME }}.app
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload updater artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: updater-${{ matrix.arch }}
|
||||
path: |
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/${{ env.APP_NAME }}.app.tar.gz
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/${{ env.APP_NAME }}.app.tar.gz.sig
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload dmg artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dmg-${{ matrix.arch }}
|
||||
path: |
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
|
||||
retention-days: 1
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 3: Merge into universal binary, re-package, and release
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: Universal binary & GitHub Release
|
||||
needs: [version, build]
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # for release notes generation
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Create universal binary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p universal
|
||||
|
||||
ARM_APP="app-aarch64/${{ env.APP_NAME }}.app"
|
||||
X86_APP="app-x86_64/${{ env.APP_NAME }}.app"
|
||||
|
||||
# Copy the aarch64 .app as base (preserves bundle structure)
|
||||
cp -R "$ARM_APP" "universal/${{ env.APP_NAME }}.app"
|
||||
|
||||
# Find the main executable inside the .app bundle
|
||||
EXEC_NAME="${{ env.APP_NAME }}"
|
||||
# Tauri uses lowercase binary name
|
||||
EXEC_NAME_LOWER=$(echo "$EXEC_NAME" | tr '[:upper:]' '[:lower:]')
|
||||
ARM_BIN="$ARM_APP/Contents/MacOS/$EXEC_NAME_LOWER"
|
||||
X86_BIN="$X86_APP/Contents/MacOS/$EXEC_NAME_LOWER"
|
||||
UNI_BIN="universal/${{ env.APP_NAME }}.app/Contents/MacOS/$EXEC_NAME_LOWER"
|
||||
|
||||
# If the binary is the product name (case-sensitive), try that too
|
||||
if [ ! -f "$ARM_BIN" ]; then
|
||||
ARM_BIN="$ARM_APP/Contents/MacOS/$EXEC_NAME"
|
||||
X86_BIN="$X86_APP/Contents/MacOS/$EXEC_NAME"
|
||||
UNI_BIN="universal/${{ env.APP_NAME }}.app/Contents/MacOS/$EXEC_NAME"
|
||||
fi
|
||||
|
||||
echo "Merging: $ARM_BIN + $X86_BIN → $UNI_BIN"
|
||||
lipo -create "$ARM_BIN" "$X86_BIN" -output "$UNI_BIN"
|
||||
lipo -info "$UNI_BIN"
|
||||
|
||||
- name: Create universal .dmg
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
DMG_NAME="${{ env.APP_NAME }}_${VERSION}_universal.dmg"
|
||||
|
||||
hdiutil create -volname "${{ env.APP_NAME }}" \
|
||||
-srcfolder "universal/${{ env.APP_NAME }}.app" \
|
||||
-ov -format UDZO \
|
||||
"universal/$DMG_NAME"
|
||||
|
||||
echo "DMG_PATH=universal/$DMG_NAME" >> "$GITHUB_ENV"
|
||||
echo "DMG_NAME=$DMG_NAME" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create universal updater tarball and sign
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Create tar.gz of the universal .app
|
||||
TARBALL="universal/${{ env.APP_NAME }}.app.tar.gz"
|
||||
tar -czf "$TARBALL" -C universal "${{ env.APP_NAME }}.app"
|
||||
|
||||
# Install tauri-cli for signing
|
||||
npm install -g @tauri-apps/cli
|
||||
|
||||
# Sign the tarball using Tauri's signer
|
||||
tauri signer sign "$TARBALL" --private-key "$TAURI_SIGNING_PRIVATE_KEY" --password "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD"
|
||||
|
||||
echo "TARBALL_PATH=$TARBALL" >> "$GITHUB_ENV"
|
||||
echo "SIG_PATH=${TARBALL}.sig" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate release notes
|
||||
id: notes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Find the previous release tag
|
||||
PREV_TAG=$(git tag --sort=-version:refname | head -n 1 || echo "")
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
# No previous tag — use all commits on main
|
||||
NOTES=$(git log --oneline --no-merges -20)
|
||||
else
|
||||
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..HEAD")
|
||||
fi
|
||||
|
||||
# Write to file for the release body
|
||||
{
|
||||
echo "## What's Changed"
|
||||
echo ""
|
||||
echo "$NOTES" | while IFS= read -r line; do
|
||||
echo "- $line"
|
||||
done
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
|
||||
} > release_notes.md
|
||||
|
||||
cat release_notes.md
|
||||
|
||||
- name: Build latest.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
TAG="${{ needs.version.outputs.tag }}"
|
||||
REPO="refactoringhq/laputa-app"
|
||||
|
||||
# Read signatures
|
||||
UNI_SIG=$(cat "${SIG_PATH}")
|
||||
ARM_SIG=$(cat updater-aarch64/${{ env.APP_NAME }}.app.tar.gz.sig)
|
||||
X86_SIG=$(cat updater-x86_64/${{ env.APP_NAME }}.app.tar.gz.sig)
|
||||
|
||||
# The universal tarball URL
|
||||
UNI_URL="https://github.com/${REPO}/releases/download/${TAG}/${{ env.APP_NAME }}.app.tar.gz"
|
||||
|
||||
# Per-arch URLs (fallback)
|
||||
ARM_URL="https://github.com/${REPO}/releases/download/${TAG}/${{ env.APP_NAME }}_aarch64.app.tar.gz"
|
||||
X86_URL="https://github.com/${REPO}/releases/download/${TAG}/${{ env.APP_NAME }}_x86_64.app.tar.gz"
|
||||
|
||||
cat > latest.json << MANIFEST
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"notes": "See https://refactoringhq.github.io/laputa-app/ for full release notes.",
|
||||
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
"signature": "${ARM_SIG}",
|
||||
"url": "${ARM_URL}"
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
"signature": "${X86_SIG}",
|
||||
"url": "${X86_URL}"
|
||||
},
|
||||
"darwin-universal": {
|
||||
"signature": "${UNI_SIG}",
|
||||
"url": "${UNI_URL}"
|
||||
}
|
||||
}
|
||||
}
|
||||
MANIFEST
|
||||
|
||||
echo "latest.json:"
|
||||
cat latest.json
|
||||
|
||||
- name: Rename per-arch updater artifacts
|
||||
run: |
|
||||
cp "updater-aarch64/${{ env.APP_NAME }}.app.tar.gz" "universal/${{ env.APP_NAME }}_aarch64.app.tar.gz"
|
||||
cp "updater-aarch64/${{ env.APP_NAME }}.app.tar.gz.sig" "universal/${{ env.APP_NAME }}_aarch64.app.tar.gz.sig"
|
||||
cp "updater-x86_64/${{ env.APP_NAME }}.app.tar.gz" "universal/${{ env.APP_NAME }}_x86_64.app.tar.gz"
|
||||
cp "updater-x86_64/${{ env.APP_NAME }}.app.tar.gz.sig" "universal/${{ env.APP_NAME }}_x86_64.app.tar.gz.sig"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.version.outputs.tag }}
|
||||
name: Laputa ${{ needs.version.outputs.version }}
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
args: --target ${{ matrix.target }}
|
||||
files: |
|
||||
${{ env.DMG_PATH }}
|
||||
${{ env.TARBALL_PATH }}
|
||||
${{ env.SIG_PATH }}
|
||||
universal/${{ env.APP_NAME }}_aarch64.app.tar.gz
|
||||
universal/${{ env.APP_NAME }}_aarch64.app.tar.gz.sig
|
||||
universal/${{ env.APP_NAME }}_x86_64.app.tar.gz
|
||||
universal/${{ env.APP_NAME }}_x86_64.app.tar.gz.sig
|
||||
latest.json
|
||||
dmg-aarch64/*.dmg
|
||||
dmg-x86_64/*.dmg
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 4: Update GitHub Pages with release history
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
pages:
|
||||
name: Update release history page
|
||||
needs: [version, release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build release history page
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p _site
|
||||
|
||||
# Fetch all releases as JSON
|
||||
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
|
||||
# Build the HTML page
|
||||
cat > _site/index.html << 'HTMLEOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Laputa — Release History</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #F7F6F3;
|
||||
color: #37352F;
|
||||
line-height: 1.6;
|
||||
padding: 2rem;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 { font-size: 1.75rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||
.subtitle { color: #787774; margin-bottom: 2rem; }
|
||||
.release {
|
||||
background: #fff;
|
||||
border: 1px solid #E9E9E7;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.release h2 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.25rem; }
|
||||
.release .meta { font-size: 0.8125rem; color: #787774; margin-bottom: 0.75rem; }
|
||||
.release .body { font-size: 0.875rem; white-space: pre-wrap; }
|
||||
.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; }
|
||||
.empty { color: #787774; text-align: center; padding: 3rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Laputa Release History</h1>
|
||||
<p class="subtitle">Auto-updated on every release</p>
|
||||
<div id="releases"></div>
|
||||
<script>
|
||||
fetch('releases.json').then(r => r.json()).then(releases => {
|
||||
const el = document.getElementById('releases');
|
||||
if (!releases.length) {
|
||||
el.innerHTML = '<p class="empty">No releases yet.</p>';
|
||||
return;
|
||||
}
|
||||
releases.forEach(r => {
|
||||
const date = new Date(r.published_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'long', day: 'numeric'
|
||||
});
|
||||
const dmgs = (r.assets || []).filter(a => a.name.endsWith('.dmg'));
|
||||
const links = dmgs.map(a =>
|
||||
'<a href="' + a.browser_download_url + '">' + a.name + '</a>'
|
||||
).join('');
|
||||
const body = (r.body || '')
|
||||
.replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/^## /gm, '').replace(/^- /gm, '\u2022 ');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'release';
|
||||
div.innerHTML =
|
||||
'<h2>' + (r.name || r.tag_name) + '</h2>' +
|
||||
'<div class="meta">' + date + ' \u00b7 ' + r.tag_name + '</div>' +
|
||||
'<div class="body">' + body + '</div>' +
|
||||
(links ? '<div class="downloads">' + links + '</div>' : '');
|
||||
el.appendChild(div);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTMLEOF
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./_site
|
||||
commit_message: "Update release history for ${{ needs.version.outputs.tag }}"
|
||||
|
||||
14536
design/auto-build-release.pen
Normal file
14536
design/auto-build-release.pen
Normal file
File diff suppressed because it is too large
Load Diff
@@ -288,3 +288,67 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
|
||||
| Cmd+S | Show "Saved" toast |
|
||||
| Cmd+W | Close active tab |
|
||||
| `[[` in editor | Open wikilink suggestion menu |
|
||||
|
||||
## Auto-Release & In-App Updates
|
||||
|
||||
### Release Pipeline
|
||||
|
||||
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
|
||||
→ 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
|
||||
→ pages job:
|
||||
→ fetch all releases via gh api
|
||||
→ build static HTML release history page
|
||||
→ deploy to gh-pages via peaceiris/actions-gh-pages
|
||||
```
|
||||
|
||||
### Versioning
|
||||
|
||||
Format: `0.YYYYMMDD.GITHUB_RUN_NUMBER` (e.g. `0.20260223.42`). The `0.` prefix keeps it SemVer-compatible while making it clear these are date-based auto-releases. The version is stamped into both `tauri.conf.json` and `Cargo.toml` dynamically in the workflow.
|
||||
|
||||
### Universal Binary
|
||||
|
||||
macOS builds produce both `aarch64-apple-darwin` and `x86_64-apple-darwin` in parallel. The release job merges them with `lipo` — copying the arm64 `.app` as the base and replacing only the main executable with a universal fat binary. The per-arch updater tarballs are also uploaded so the Tauri updater downloads only the relevant architecture (smaller download).
|
||||
|
||||
### Updater Endpoint
|
||||
|
||||
The Tauri updater plugin is configured to fetch:
|
||||
```
|
||||
https://github.com/refactoringhq/laputa-app/releases/latest/download/latest.json
|
||||
```
|
||||
|
||||
This JSON manifest contains `version`, `pub_date`, `notes`, and per-platform entries (`darwin-aarch64`, `darwin-x86_64`) with `url` and `signature` fields. The updater compares the manifest version against the running app version, downloads the matching platform artifact, verifies the signature, and installs it.
|
||||
|
||||
### In-App Update UI
|
||||
|
||||
```
|
||||
App startup (3s delay)
|
||||
→ useUpdater.check()
|
||||
→ idle (no update) → no UI shown
|
||||
→ available → UpdateBanner: "Laputa X.Y.Z is available" + Release Notes + Update Now + X
|
||||
→ user clicks Update Now → downloading → progress bar
|
||||
→ download complete → ready → "Restart to apply" + Restart Now button
|
||||
→ user clicks Restart → relaunch()
|
||||
→ network error / 404 → fail silently, no UI
|
||||
```
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| `useUpdater` | `src/hooks/useUpdater.ts` | State machine: idle → available → downloading → ready → error |
|
||||
| `UpdateBanner` | `src/components/UpdateBanner.tsx` | Top-of-app notification bar |
|
||||
| `restartApp` | `src/hooks/useUpdater.ts` | Calls `@tauri-apps/plugin-process` relaunch |
|
||||
|
||||
### GitHub Pages
|
||||
|
||||
Release history site at `https://refactoringhq.github.io/laputa-app/`. Auto-updated by the workflow after each release. The page loads `releases.json` (deployed alongside) and renders each release with date, notes, and `.dmg` download links. Linked from the in-app "Release Notes" button.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -49,7 +49,10 @@ fn get_api_key() -> Result<String, String> {
|
||||
|
||||
fn build_request(req: &AiChatRequest) -> AnthropicRequest {
|
||||
AnthropicRequest {
|
||||
model: req.model.clone().unwrap_or_else(|| "claude-3-5-haiku-20241022".to_string()),
|
||||
model: req
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "claude-3-5-haiku-20241022".to_string()),
|
||||
max_tokens: req.max_tokens.unwrap_or(4096),
|
||||
messages: req.messages.clone(),
|
||||
system: req.system.clone(),
|
||||
@@ -137,8 +140,12 @@ mod tests {
|
||||
fn test_extract_response_text() {
|
||||
let resp = AnthropicResponse {
|
||||
content: vec![
|
||||
ContentBlock { text: Some("Hello ".to_string()) },
|
||||
ContentBlock { text: Some("world".to_string()) },
|
||||
ContentBlock {
|
||||
text: Some("Hello ".to_string()),
|
||||
},
|
||||
ContentBlock {
|
||||
text: Some("world".to_string()),
|
||||
},
|
||||
ContentBlock { text: None },
|
||||
],
|
||||
model: "test".to_string(),
|
||||
|
||||
@@ -54,14 +54,20 @@ impl FrontmatterValue {
|
||||
pub fn to_yaml_value(&self) -> String {
|
||||
match self {
|
||||
FrontmatterValue::String(s) => {
|
||||
if needs_yaml_quoting(s) { quote_yaml_string(s) } else { s.clone() }
|
||||
if needs_yaml_quoting(s) {
|
||||
quote_yaml_string(s)
|
||||
} else {
|
||||
s.clone()
|
||||
}
|
||||
}
|
||||
FrontmatterValue::Number(n) => format_yaml_number(*n),
|
||||
FrontmatterValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
|
||||
FrontmatterValue::List(items) if items.is_empty() => "[]".to_string(),
|
||||
FrontmatterValue::List(items) => {
|
||||
items.iter().map(|item| format_list_item(item)).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
FrontmatterValue::List(items) => items
|
||||
.iter()
|
||||
.map(|item| format_list_item(item))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
FrontmatterValue::Null => "null".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -69,7 +75,8 @@ impl FrontmatterValue {
|
||||
|
||||
/// Check whether a YAML key needs quoting (contains spaces, special chars, etc.).
|
||||
fn needs_key_quoting(key: &str) -> bool {
|
||||
key.chars().any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
|
||||
key.chars()
|
||||
.any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
|
||||
}
|
||||
|
||||
/// Format a key for YAML output (quote if necessary)
|
||||
@@ -84,21 +91,21 @@ pub fn format_yaml_key(key: &str) -> String {
|
||||
/// 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
|
||||
}
|
||||
|
||||
@@ -121,7 +128,8 @@ fn is_list_continuation(line: &str) -> bool {
|
||||
/// 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 fm_end = content[4..].find("\n---")
|
||||
let fm_end = content[4..]
|
||||
.find("\n---")
|
||||
.map(|i| i + 4)
|
||||
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
|
||||
Ok((&content[4..fm_end], &content[fm_end + 4..]))
|
||||
@@ -168,7 +176,11 @@ fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue
|
||||
}
|
||||
|
||||
/// Internal function to update frontmatter content
|
||||
pub fn update_frontmatter_content(content: &str, key: &str, value: Option<FrontmatterValue>) -> Result<String, String> {
|
||||
pub fn update_frontmatter_content(
|
||||
content: &str,
|
||||
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)),
|
||||
@@ -192,15 +204,14 @@ where
|
||||
if !file_path.exists() {
|
||||
return Err(format!("File does not exist: {}", path));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(file_path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", path, e))?;
|
||||
|
||||
|
||||
let content =
|
||||
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
|
||||
|
||||
let updated = transform(&content)?;
|
||||
|
||||
fs::write(file_path, &updated)
|
||||
.map_err(|e| format!("Failed to write {}: {}", path, e))?;
|
||||
|
||||
|
||||
fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
@@ -211,7 +222,12 @@ mod tests {
|
||||
#[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();
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Status: Active"));
|
||||
assert!(!updated.contains("Status: Draft"));
|
||||
}
|
||||
@@ -219,7 +235,12 @@ mod tests {
|
||||
#[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();
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Owner: Luca"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
@@ -227,7 +248,12 @@ mod tests {
|
||||
#[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();
|
||||
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"));
|
||||
}
|
||||
@@ -235,7 +261,15 @@ mod tests {
|
||||
#[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();
|
||||
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\""));
|
||||
@@ -244,7 +278,12 @@ mod tests {
|
||||
#[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();
|
||||
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"));
|
||||
@@ -271,7 +310,12 @@ mod tests {
|
||||
#[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();
|
||||
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"));
|
||||
@@ -280,7 +324,9 @@ mod tests {
|
||||
#[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();
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Reviewed", Some(FrontmatterValue::Bool(true)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Reviewed: true"));
|
||||
}
|
||||
|
||||
@@ -324,19 +370,34 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_bool_like() {
|
||||
assert_eq!(FrontmatterValue::String("true".to_string()).to_yaml_value(), "\"true\"");
|
||||
assert_eq!(FrontmatterValue::String("false".to_string()).to_yaml_value(), "\"false\"");
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("true".to_string()).to_yaml_value(),
|
||||
"\"true\""
|
||||
);
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("false".to_string()).to_yaml_value(),
|
||||
"\"false\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_null_like() {
|
||||
assert_eq!(FrontmatterValue::String("null".to_string()).to_yaml_value(), "\"null\"");
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("null".to_string()).to_yaml_value(),
|
||||
"\"null\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_number_like() {
|
||||
assert_eq!(FrontmatterValue::String("42".to_string()).to_yaml_value(), "\"42\"");
|
||||
assert_eq!(FrontmatterValue::String("3.14".to_string()).to_yaml_value(), "\"3.14\"");
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("42".to_string()).to_yaml_value(),
|
||||
"\"42\""
|
||||
);
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("3.14".to_string()).to_yaml_value(),
|
||||
"\"3.14\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -379,35 +440,46 @@ mod tests {
|
||||
#[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();
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
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())));
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Malformed frontmatter"));
|
||||
}
|
||||
@@ -472,7 +544,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_roundtrip_update_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "Status", Some(FrontmatterValue::String("Active".to_string()))).unwrap();
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
// Parse back with gray_matter
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
@@ -487,7 +564,15 @@ mod tests {
|
||||
#[test]
|
||||
fn test_roundtrip_update_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "aliases", Some(FrontmatterValue::List(vec!["A".to_string(), "B".to_string()]))).unwrap();
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec![
|
||||
"A".to_string(),
|
||||
"B".to_string(),
|
||||
])),
|
||||
)
|
||||
.unwrap();
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
@@ -508,7 +593,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_roundtrip_add_then_delete() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let with_owner = update_frontmatter_content(content, "Owner", Some(FrontmatterValue::String("Luca".to_string()))).unwrap();
|
||||
let with_owner = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(with_owner.contains("Owner: Luca"));
|
||||
let without_owner = update_frontmatter_content(&with_owner, "Owner", None).unwrap();
|
||||
assert!(!without_owner.contains("Owner"));
|
||||
|
||||
@@ -181,8 +181,8 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
|
||||
let status_out = String::from_utf8_lossy(&status.stdout);
|
||||
if status_out.starts_with("??") {
|
||||
// Untracked file: show entire content as added
|
||||
let content = std::fs::read_to_string(file)
|
||||
.map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
let content =
|
||||
std::fs::read_to_string(file).map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
let lines: Vec<String> = content.lines().map(|l| format!("+{}", l)).collect();
|
||||
return Ok(format!(
|
||||
"diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}",
|
||||
@@ -197,7 +197,11 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
|
||||
}
|
||||
|
||||
/// Get git diff for a specific file at a given commit (compared to its parent).
|
||||
pub fn get_file_diff_at_commit(vault_path: &str, file_path: &str, commit_hash: &str) -> Result<String, String> {
|
||||
pub fn get_file_diff_at_commit(
|
||||
vault_path: &str,
|
||||
file_path: &str,
|
||||
commit_hash: &str,
|
||||
) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
|
||||
@@ -211,7 +215,13 @@ pub fn get_file_diff_at_commit(vault_path: &str, file_path: &str, commit_hash: &
|
||||
|
||||
// Show diff between commit^ and commit for this file
|
||||
let output = Command::new("git")
|
||||
.args(["diff", &format!("{}^", commit_hash), commit_hash, "--", relative_str])
|
||||
.args([
|
||||
"diff",
|
||||
&format!("{}^", commit_hash),
|
||||
commit_hash,
|
||||
"--",
|
||||
relative_str,
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git diff: {}", e))?;
|
||||
@@ -356,11 +366,7 @@ mod tests {
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let history = get_file_history(
|
||||
vault.to_str().unwrap(),
|
||||
file.to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(history[0].message, "Update test");
|
||||
@@ -377,11 +383,7 @@ mod tests {
|
||||
let file = vault.join("new.md");
|
||||
fs::write(&file, "# New\n").unwrap();
|
||||
|
||||
let history = get_file_history(
|
||||
vault.to_str().unwrap(),
|
||||
file.to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
@@ -436,11 +438,7 @@ mod tests {
|
||||
|
||||
fs::write(&file, "# Test\n\nModified content.").unwrap();
|
||||
|
||||
let diff = get_file_diff(
|
||||
vault.to_str().unwrap(),
|
||||
file.to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let diff = get_file_diff(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(!diff.is_empty());
|
||||
assert!(diff.contains("-Original content."));
|
||||
@@ -485,12 +483,8 @@ mod tests {
|
||||
.unwrap();
|
||||
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
|
||||
|
||||
let diff = get_file_diff_at_commit(
|
||||
vault.to_str().unwrap(),
|
||||
file.to_str().unwrap(),
|
||||
&hash,
|
||||
)
|
||||
.unwrap();
|
||||
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
||||
.unwrap();
|
||||
|
||||
assert!(!diff.is_empty());
|
||||
assert!(diff.contains("-Original content."));
|
||||
@@ -522,12 +516,8 @@ mod tests {
|
||||
.unwrap();
|
||||
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
|
||||
|
||||
let diff = get_file_diff_at_commit(
|
||||
vault.to_str().unwrap(),
|
||||
file.to_str().unwrap(),
|
||||
&hash,
|
||||
)
|
||||
.unwrap();
|
||||
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
||||
.unwrap();
|
||||
|
||||
assert!(!diff.is_empty());
|
||||
assert!(diff.contains("+# Initial"));
|
||||
|
||||
@@ -125,7 +125,12 @@ pub async fn github_create_repo(
|
||||
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
|
||||
if dest.exists() && dest.read_dir().map(|mut d| d.next().is_some()).unwrap_or(false) {
|
||||
if dest.exists()
|
||||
&& dest
|
||||
.read_dir()
|
||||
.map(|mut d| d.next().is_some())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not empty",
|
||||
local_path
|
||||
@@ -163,7 +168,10 @@ fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
|
||||
// Handle URLs that already have a host
|
||||
Ok(format!("https://oauth2:{}@{}", token, rest))
|
||||
} else {
|
||||
Err(format!("Unsupported URL format: {}. Use an HTTPS URL.", url))
|
||||
Err(format!(
|
||||
"Unsupported URL format: {}. Use an HTTPS URL.",
|
||||
url
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +253,11 @@ mod tests {
|
||||
let path = dir.path();
|
||||
std::fs::write(path.join("existing.txt"), "data").unwrap();
|
||||
|
||||
let result = clone_repo("https://github.com/test/repo.git", "token", path.to_str().unwrap());
|
||||
let result = clone_repo(
|
||||
"https://github.com/test/repo.git",
|
||||
"token",
|
||||
path.to_str().unwrap(),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not empty"));
|
||||
}
|
||||
@@ -255,7 +267,11 @@ mod tests {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("new-clone");
|
||||
|
||||
let result = clone_repo("git@github.com:user/repo.git", "token", dest.to_str().unwrap());
|
||||
let result = clone_repo(
|
||||
"git@github.com:user/repo.git",
|
||||
"token",
|
||||
dest.to_str().unwrap(),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unsupported URL format"));
|
||||
}
|
||||
@@ -268,7 +284,11 @@ mod tests {
|
||||
std::fs::create_dir(&dest).unwrap();
|
||||
|
||||
// This will fail at the git clone step (invalid URL) but should pass the directory check
|
||||
let result = clone_repo("https://github.com/nonexistent/repo.git", "token", dest.to_str().unwrap());
|
||||
let result = clone_repo(
|
||||
"https://github.com/nonexistent/repo.git",
|
||||
"token",
|
||||
dest.to_str().unwrap(),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
// Should fail at git clone, not at directory check
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
@@ -280,9 +300,21 @@ mod tests {
|
||||
let path = dir.path();
|
||||
|
||||
// Initialize a git repo
|
||||
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();
|
||||
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();
|
||||
|
||||
let result = configure_remote_auth(
|
||||
path.to_str().unwrap(),
|
||||
|
||||
@@ -7,11 +7,11 @@ pub mod settings;
|
||||
pub mod vault;
|
||||
|
||||
use ai_chat::{AiChatRequest, AiChatResponse};
|
||||
use frontmatter::FrontmatterValue;
|
||||
use git::{GitCommit, ModifiedFile};
|
||||
use github::GithubRepo;
|
||||
use settings::Settings;
|
||||
use vault::{VaultEntry, RenameResult};
|
||||
use frontmatter::FrontmatterValue;
|
||||
use vault::{RenameResult, VaultEntry};
|
||||
|
||||
#[tauri::command]
|
||||
fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
||||
@@ -24,7 +24,11 @@ fn get_note_content(path: String) -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_frontmatter(path: String, key: String, value: FrontmatterValue) -> Result<String, String> {
|
||||
fn update_frontmatter(
|
||||
path: String,
|
||||
key: String,
|
||||
value: FrontmatterValue,
|
||||
) -> Result<String, String> {
|
||||
vault::update_frontmatter(&path, &key, value)
|
||||
}
|
||||
|
||||
@@ -49,7 +53,11 @@ fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_file_diff_at_commit(vault_path: String, path: String, commit_hash: String) -> Result<String, String> {
|
||||
fn get_file_diff_at_commit(
|
||||
vault_path: String,
|
||||
path: String,
|
||||
commit_hash: String,
|
||||
) -> Result<String, String> {
|
||||
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
|
||||
}
|
||||
|
||||
@@ -74,7 +82,11 @@ fn save_image(vault_path: String, filename: String, data: String) -> Result<Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn rename_note(vault_path: String, old_path: String, new_title: String) -> Result<RenameResult, String> {
|
||||
fn rename_note(
|
||||
vault_path: String,
|
||||
old_path: String,
|
||||
new_title: String,
|
||||
) -> Result<RenameResult, String> {
|
||||
vault::rename_note(&vault_path, &old_path, &new_title)
|
||||
}
|
||||
|
||||
@@ -99,7 +111,11 @@ async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_create_repo(token: String, name: String, private: bool) -> Result<GithubRepo, String> {
|
||||
async fn github_create_repo(
|
||||
token: String,
|
||||
name: String,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
github::github_create_repo(&token, &name, private).await
|
||||
}
|
||||
|
||||
@@ -122,7 +138,8 @@ pub fn run() {
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
app.handle()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
app.handle().plugin(tauri_plugin_process::init())?;
|
||||
menu::setup_menu(app)?;
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
laputa_lib::run();
|
||||
laputa_lib::run();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use tauri::{menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}, App, Emitter};
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder},
|
||||
App, Emitter,
|
||||
};
|
||||
|
||||
const VIEW_ITEMS: [(&str, &str, &str); 3] = [
|
||||
("view-editor-only", "Editor Only", "CmdOrCtrl+1"),
|
||||
@@ -17,9 +20,7 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
let view_submenu = view_menu.build()?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&view_submenu)
|
||||
.build()?;
|
||||
let menu = MenuBuilder::new(app).item(&view_submenu).build()?;
|
||||
|
||||
app.set_menu(menu)?;
|
||||
|
||||
|
||||
@@ -20,10 +20,9 @@ fn get_settings_at(path: &PathBuf) -> Result<Settings, String> {
|
||||
if !path.exists() {
|
||||
return Ok(Settings::default());
|
||||
}
|
||||
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 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))
|
||||
}
|
||||
|
||||
fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> {
|
||||
@@ -34,16 +33,27 @@ fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> {
|
||||
|
||||
// Trim whitespace and convert empty strings to None
|
||||
let cleaned = Settings {
|
||||
anthropic_key: settings.anthropic_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
|
||||
openai_key: settings.openai_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
|
||||
google_key: settings.google_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
|
||||
github_token: settings.github_token.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
|
||||
anthropic_key: settings
|
||||
.anthropic_key
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
openai_key: settings
|
||||
.openai_key
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
google_key: settings
|
||||
.google_key
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
github_token: settings
|
||||
.github_token
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&cleaned)
|
||||
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
|
||||
fs::write(path, json)
|
||||
.map_err(|e| format!("Failed to write settings: {}", e))
|
||||
fs::write(path, json).map_err(|e| format!("Failed to write settings: {}", e))
|
||||
}
|
||||
|
||||
pub fn get_settings() -> Result<Settings, String> {
|
||||
@@ -71,7 +81,15 @@ mod tests {
|
||||
let s = Settings::default();
|
||||
assert_eq!(
|
||||
format!("{:?}", s),
|
||||
format!("{:?}", Settings { anthropic_key: None, openai_key: None, google_key: None, github_token: None })
|
||||
format!(
|
||||
"{:?}",
|
||||
Settings {
|
||||
anthropic_key: None,
|
||||
openai_key: None,
|
||||
google_key: None,
|
||||
github_token: None
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,9 +158,19 @@ mod tests {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nested").join("dir").join("settings.json");
|
||||
|
||||
save_settings_at(&path, Settings { anthropic_key: Some("key".to_string()), ..Default::default() }).unwrap();
|
||||
save_settings_at(
|
||||
&path,
|
||||
Settings {
|
||||
anthropic_key: Some("key".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(get_settings_at(&path).unwrap().anthropic_key.as_deref(), Some("key"));
|
||||
assert_eq!(
|
||||
get_settings_at(&path).unwrap().anthropic_key.as_deref(),
|
||||
Some("key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::frontmatter::{with_frontmatter, update_frontmatter_content};
|
||||
use crate::frontmatter::{update_frontmatter_content, with_frontmatter};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct VaultEntry {
|
||||
@@ -178,7 +178,9 @@ fn without_h1_line(s: &str) -> Option<&str> {
|
||||
fn collect_until(chars: &mut impl Iterator<Item = char>, delimiter: char) -> String {
|
||||
let mut buf = String::new();
|
||||
for c in chars.by_ref() {
|
||||
if c == delimiter { break; }
|
||||
if c == delimiter {
|
||||
break;
|
||||
}
|
||||
buf.push(c);
|
||||
}
|
||||
buf
|
||||
@@ -187,7 +189,9 @@ fn collect_until(chars: &mut impl Iterator<Item = char>, delimiter: char) -> Str
|
||||
/// Skip all chars until a delimiter (consuming the delimiter).
|
||||
fn skip_until(chars: &mut impl Iterator<Item = char>, delimiter: char) {
|
||||
for c in chars.by_ref() {
|
||||
if c == delimiter { break; }
|
||||
if c == delimiter {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,19 +223,26 @@ fn strip_markdown_chars(s: &str) -> String {
|
||||
/// Parse frontmatter from raw YAML data extracted by gray_matter.
|
||||
fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
// Convert HashMap to serde_json::Value for deserialization
|
||||
let value = serde_json::Value::Object(
|
||||
data.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect(),
|
||||
);
|
||||
let value =
|
||||
serde_json::Value::Object(data.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
|
||||
serde_json::from_value(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Known non-relationship frontmatter keys to skip (case-insensitive comparison).
|
||||
/// Only skip keys that can never contain wikilinks.
|
||||
const SKIP_KEYS: &[&str] = &[
|
||||
"is a", "aliases", "status", "cadence", "archived", "trashed", "trashed at",
|
||||
"created at", "created time", "icon", "color", "order",
|
||||
"is a",
|
||||
"aliases",
|
||||
"status",
|
||||
"cadence",
|
||||
"archived",
|
||||
"trashed",
|
||||
"trashed at",
|
||||
"created at",
|
||||
"created time",
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
];
|
||||
|
||||
/// Check if a string contains a wikilink pattern `[[...]]`.
|
||||
@@ -243,7 +254,9 @@ fn contains_wikilink(s: &str) -> bool {
|
||||
/// Returns a HashMap where each key is the original frontmatter field name
|
||||
/// and the value is a Vec of wikilink strings found in that field.
|
||||
/// Handles both single string values and arrays of strings.
|
||||
fn extract_relationships(data: &HashMap<String, serde_json::Value>) -> HashMap<String, Vec<String>> {
|
||||
fn extract_relationships(
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
) -> HashMap<String, Vec<String>> {
|
||||
let mut relationships = HashMap::new();
|
||||
|
||||
for (key, value) in data {
|
||||
@@ -296,7 +309,8 @@ fn infer_type_from_folder(folder: &str) -> String {
|
||||
"essay" => "Essay",
|
||||
"evergreen" => "Evergreen",
|
||||
_ => return capitalize_first(folder),
|
||||
}.to_string()
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Resolve `is_a` from frontmatter, falling back to parent folder inference.
|
||||
@@ -312,28 +326,35 @@ fn resolve_is_a(fm_is_a: Option<StringOrList>, path: &Path) -> Option<String> {
|
||||
|
||||
/// Parse created_at from frontmatter (prefer "Created at" over "Created time").
|
||||
fn parse_created_at(fm: &Frontmatter) -> Option<u64> {
|
||||
fm.created_at.as_ref().and_then(|s| parse_iso_date(s))
|
||||
fm.created_at
|
||||
.as_ref()
|
||||
.and_then(|s| parse_iso_date(s))
|
||||
.or_else(|| fm.created_time.as_ref().and_then(|s| parse_iso_date(s)))
|
||||
}
|
||||
|
||||
/// Extract frontmatter and relationships from parsed gray_matter data.
|
||||
fn extract_fm_and_rels(data: Option<gray_matter::Pod>) -> (Frontmatter, HashMap<String, Vec<String>>) {
|
||||
fn extract_fm_and_rels(
|
||||
data: Option<gray_matter::Pod>,
|
||||
) -> (Frontmatter, HashMap<String, Vec<String>>) {
|
||||
let hash = match data {
|
||||
Some(gray_matter::Pod::Hash(map)) => map,
|
||||
_ => return (Frontmatter::default(), HashMap::new()),
|
||||
};
|
||||
let json_map: HashMap<String, serde_json::Value> = hash
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, pod_to_json(v)))
|
||||
.collect();
|
||||
(parse_frontmatter(&json_map), extract_relationships(&json_map))
|
||||
let json_map: HashMap<String, serde_json::Value> =
|
||||
hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
|
||||
(
|
||||
parse_frontmatter(&json_map),
|
||||
extract_relationships(&json_map),
|
||||
)
|
||||
}
|
||||
|
||||
/// Read file metadata (modified_at timestamp, file size).
|
||||
fn read_file_metadata(path: &Path) -> Result<(Option<u64>, u64), String> {
|
||||
let metadata = fs::metadata(path)
|
||||
.map_err(|e| format!("Failed to stat {}: {}", path.display(), e))?;
|
||||
let modified_at = metadata.modified().ok()
|
||||
let metadata =
|
||||
fs::metadata(path).map_err(|e| format!("Failed to stat {}: {}", path.display(), e))?;
|
||||
let modified_at = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs());
|
||||
Ok((modified_at, metadata.len()))
|
||||
@@ -343,7 +364,8 @@ fn read_file_metadata(path: &Path) -> Result<(Option<u64>, u64), String> {
|
||||
pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
|
||||
let filename = path.file_name()
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -373,17 +395,32 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
|
||||
Ok(VaultEntry {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
filename, title, is_a, snippet, relationships,
|
||||
aliases: frontmatter.aliases.map(|a| a.into_vec()).unwrap_or_default(),
|
||||
belongs_to: frontmatter.belongs_to.map(|b| b.into_vec()).unwrap_or_default(),
|
||||
related_to: frontmatter.related_to.map(|r| r.into_vec()).unwrap_or_default(),
|
||||
filename,
|
||||
title,
|
||||
is_a,
|
||||
snippet,
|
||||
relationships,
|
||||
aliases: frontmatter
|
||||
.aliases
|
||||
.map(|a| a.into_vec())
|
||||
.unwrap_or_default(),
|
||||
belongs_to: frontmatter
|
||||
.belongs_to
|
||||
.map(|b| b.into_vec())
|
||||
.unwrap_or_default(),
|
||||
related_to: frontmatter
|
||||
.related_to
|
||||
.map(|r| r.into_vec())
|
||||
.unwrap_or_default(),
|
||||
status: frontmatter.status,
|
||||
owner: frontmatter.owner,
|
||||
cadence: frontmatter.cadence,
|
||||
archived: frontmatter.archived.unwrap_or(false),
|
||||
trashed: frontmatter.trashed.unwrap_or(false),
|
||||
trashed_at: frontmatter.trashed_at.as_deref().and_then(parse_iso_date),
|
||||
modified_at, created_at, file_size,
|
||||
modified_at,
|
||||
created_at,
|
||||
file_size,
|
||||
icon: frontmatter.icon,
|
||||
color: frontmatter.color,
|
||||
order: frontmatter.order,
|
||||
@@ -435,10 +472,8 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
|
||||
serde_json::Value::Array(arr.into_iter().map(pod_to_json).collect())
|
||||
}
|
||||
gray_matter::Pod::Hash(map) => {
|
||||
let obj: serde_json::Map<String, serde_json::Value> = map
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, pod_to_json(v)))
|
||||
.collect();
|
||||
let obj: serde_json::Map<String, serde_json::Value> =
|
||||
map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
|
||||
serde_json::Value::Object(obj)
|
||||
}
|
||||
gray_matter::Pod::Null => serde_json::Value::Null,
|
||||
@@ -469,12 +504,7 @@ pub fn purge_trash(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file()
|
||||
|| path
|
||||
.extension()
|
||||
.map(|ext| ext != "md")
|
||||
.unwrap_or(true)
|
||||
{
|
||||
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -528,8 +558,7 @@ pub fn get_note_content(path: &str) -> Result<String, String> {
|
||||
if !file_path.is_file() {
|
||||
return Err(format!("Path is not a file: {}", path));
|
||||
}
|
||||
fs::read_to_string(file_path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", path, e))
|
||||
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))
|
||||
}
|
||||
|
||||
/// Scan a directory recursively for .md files and return VaultEntry for each.
|
||||
@@ -601,7 +630,9 @@ fn run_git(vault: &Path, args: &[&str]) -> Option<String> {
|
||||
|
||||
/// Parse a git status porcelain line into (status_code, file_path).
|
||||
fn parse_porcelain_line(line: &str) -> Option<(&str, String)> {
|
||||
if line.len() < 3 { return None; }
|
||||
if line.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
Some((&line[..2], line[3..].trim().to_string()))
|
||||
}
|
||||
|
||||
@@ -612,7 +643,8 @@ fn is_new_file_status(status: &str) -> bool {
|
||||
|
||||
/// Extract .md file paths from git diff --name-only output.
|
||||
fn collect_md_paths_from_diff(stdout: &str) -> Vec<String> {
|
||||
stdout.lines()
|
||||
stdout
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty() && line.ends_with(".md"))
|
||||
.map(|line| line.to_string())
|
||||
.collect()
|
||||
@@ -620,7 +652,8 @@ fn collect_md_paths_from_diff(stdout: &str) -> Vec<String> {
|
||||
|
||||
/// Extract .md file paths from git status --porcelain output.
|
||||
fn collect_md_paths_from_porcelain(stdout: &str) -> Vec<String> {
|
||||
stdout.lines()
|
||||
stdout
|
||||
.lines()
|
||||
.filter_map(parse_porcelain_line)
|
||||
.filter(|(_, path)| path.ends_with(".md"))
|
||||
.map(|(_, path)| path)
|
||||
@@ -651,7 +684,8 @@ fn git_uncommitted_new_files(vault: &Path) -> Vec<String> {
|
||||
Some(s) => s,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
stdout.lines()
|
||||
stdout
|
||||
.lines()
|
||||
.filter_map(parse_porcelain_line)
|
||||
.filter(|(status, path)| path.ends_with(".md") && is_new_file_status(status))
|
||||
.map(|(_, path)| path)
|
||||
@@ -673,7 +707,8 @@ fn write_cache(vault: &Path, cache: &VaultCache) {
|
||||
fn to_relative_path(abs_path: &str, vault: &Path) -> String {
|
||||
let vault_str = vault.to_string_lossy();
|
||||
let with_slash = format!("{}/", vault_str);
|
||||
abs_path.strip_prefix(&with_slash)
|
||||
abs_path
|
||||
.strip_prefix(&with_slash)
|
||||
.or_else(|| abs_path.strip_prefix(vault_str.as_ref()))
|
||||
.unwrap_or(abs_path)
|
||||
.to_string()
|
||||
@@ -681,10 +716,15 @@ fn to_relative_path(abs_path: &str, vault: &Path) -> String {
|
||||
|
||||
/// Parse .md files from a list of relative paths, skipping any that don't exist.
|
||||
fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
|
||||
rel_paths.iter()
|
||||
rel_paths
|
||||
.iter()
|
||||
.filter_map(|rel| {
|
||||
let abs = vault.join(rel);
|
||||
if abs.is_file() { parse_md_file(&abs).ok() } else { None }
|
||||
if abs.is_file() {
|
||||
parse_md_file(&abs).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -692,7 +732,13 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
|
||||
/// Sort entries by modified_at descending and write the cache.
|
||||
fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String) -> Vec<VaultEntry> {
|
||||
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
write_cache(vault, &VaultCache { commit_hash: hash, entries: entries.clone() });
|
||||
write_cache(
|
||||
vault,
|
||||
&VaultCache {
|
||||
commit_hash: hash,
|
||||
entries: entries.clone(),
|
||||
},
|
||||
);
|
||||
entries
|
||||
}
|
||||
|
||||
@@ -700,7 +746,8 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
|
||||
fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
|
||||
let new_files = git_uncommitted_new_files(vault);
|
||||
let mut entries = cache.entries;
|
||||
let existing: std::collections::HashSet<String> = entries.iter()
|
||||
let existing: std::collections::HashSet<String> = entries
|
||||
.iter()
|
||||
.map(|e| to_relative_path(&e.path, vault))
|
||||
.collect();
|
||||
|
||||
@@ -716,11 +763,17 @@ fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
|
||||
}
|
||||
|
||||
/// Handle different-commit cache: incremental update via git diff.
|
||||
fn update_different_commit(vault: &Path, cache: VaultCache, current_hash: String) -> Vec<VaultEntry> {
|
||||
fn update_different_commit(
|
||||
vault: &Path,
|
||||
cache: VaultCache,
|
||||
current_hash: String,
|
||||
) -> Vec<VaultEntry> {
|
||||
let changed_files = git_changed_files(vault, &cache.commit_hash, ¤t_hash);
|
||||
let changed_set: std::collections::HashSet<String> = changed_files.iter().cloned().collect();
|
||||
|
||||
let mut entries: Vec<VaultEntry> = cache.entries.into_iter()
|
||||
let mut entries: Vec<VaultEntry> = cache
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
|
||||
.collect();
|
||||
entries.extend(parse_files_at(vault, &changed_files));
|
||||
@@ -733,7 +786,10 @@ fn update_different_commit(vault: &Path, cache: VaultCache, current_hash: String
|
||||
pub fn scan_vault_cached(vault_path: &str) -> Result<Vec<VaultEntry>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
if !vault.exists() || !vault.is_dir() {
|
||||
return Err(format!("Vault path does not exist or is not a directory: {}", vault_path));
|
||||
return Err(format!(
|
||||
"Vault path does not exist or is not a directory: {}",
|
||||
vault_path
|
||||
));
|
||||
}
|
||||
|
||||
let current_hash = match git_head_hash(vault) {
|
||||
@@ -758,13 +814,21 @@ pub fn scan_vault_cached(vault_path: &str) -> Result<Vec<VaultEntry>, String> {
|
||||
pub use crate::frontmatter::FrontmatterValue;
|
||||
|
||||
/// Update a single frontmatter property in a markdown file
|
||||
pub fn update_frontmatter(path: &str, key: &str, value: FrontmatterValue) -> Result<String, String> {
|
||||
with_frontmatter(path, |content| update_frontmatter_content(content, key, Some(value.clone())))
|
||||
pub fn update_frontmatter(
|
||||
path: &str,
|
||||
key: &str,
|
||||
value: FrontmatterValue,
|
||||
) -> Result<String, String> {
|
||||
with_frontmatter(path, |content| {
|
||||
update_frontmatter_content(content, key, Some(value.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete a frontmatter property from a markdown file
|
||||
pub fn delete_frontmatter_property(path: &str, key: &str) -> Result<String, String> {
|
||||
with_frontmatter(path, |content| update_frontmatter_content(content, key, None))
|
||||
with_frontmatter(path, |content| {
|
||||
update_frontmatter_content(content, key, None)
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a character is safe for use in filenames (alphanumeric, dot, dash, underscore).
|
||||
@@ -802,8 +866,7 @@ pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result<String
|
||||
.decode(data)
|
||||
.map_err(|e| format!("Invalid base64 data: {}", e))?;
|
||||
|
||||
fs::write(&target_path, bytes)
|
||||
.map_err(|e| format!("Failed to write image: {}", e))?;
|
||||
fs::write(&target_path, bytes).map_err(|e| format!("Failed to write image: {}", e))?;
|
||||
|
||||
Ok(target_path.to_string_lossy().to_string())
|
||||
}
|
||||
@@ -837,9 +900,16 @@ fn update_h1_title(content: &str, new_title: &str) -> String {
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
let result: Vec<String> = content.lines().map(|l| {
|
||||
if l.trim().starts_with("# ") { format!("# {}", new_title) } else { l.to_string() }
|
||||
}).collect();
|
||||
let result: Vec<String> = content
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.trim().starts_with("# ") {
|
||||
format!("# {}", new_title)
|
||||
} else {
|
||||
l.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let joined = result.join("\n");
|
||||
if content.ends_with('\n') && !joined.ends_with('\n') {
|
||||
@@ -861,9 +931,7 @@ fn build_wikilink_pattern(old_title: &str, old_path_stem: &str) -> Option<Regex>
|
||||
|
||||
/// Check if a path is a vault markdown file eligible for wikilink replacement.
|
||||
fn is_replaceable_md_file(path: &Path, exclude: &Path) -> bool {
|
||||
path.is_file()
|
||||
&& path != exclude
|
||||
&& path.extension().is_some_and(|ext| ext == "md")
|
||||
path.is_file() && path != exclude && path.extension().is_some_and(|ext| ext == "md")
|
||||
}
|
||||
|
||||
/// Replace wikilink references in a single file's content. Returns updated content if changed.
|
||||
@@ -871,13 +939,15 @@ fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> O
|
||||
if !re.is_match(content) {
|
||||
return None;
|
||||
}
|
||||
let replaced = re.replace_all(content, |caps: ®ex::Captures| {
|
||||
match caps.get(1) {
|
||||
Some(pipe) => format!("[[{}{}]]", new_title, pipe.as_str()),
|
||||
None => format!("[[{}]]", new_title),
|
||||
}
|
||||
let replaced = re.replace_all(content, |caps: ®ex::Captures| match caps.get(1) {
|
||||
Some(pipe) => format!("[[{}{}]]", new_title, pipe.as_str()),
|
||||
None => format!("[[{}]]", new_title),
|
||||
});
|
||||
if replaced != content { Some(replaced.into_owned()) } else { None }
|
||||
if replaced != content {
|
||||
Some(replaced.into_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for a vault-wide wikilink replacement.
|
||||
@@ -908,16 +978,19 @@ fn update_wikilinks_in_vault(params: &WikilinkReplacement) -> usize {
|
||||
};
|
||||
|
||||
let files = collect_md_files(params.vault_path, params.exclude_path);
|
||||
files.iter().filter(|path| {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
match replace_wikilinks_in_content(&content, &re, params.new_title) {
|
||||
Some(new_content) => fs::write(path, &new_content).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}).count()
|
||||
files
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
match replace_wikilinks_in_content(&content, &re, params.new_title) {
|
||||
Some(new_content) => fs::write(path, &new_content).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Check if frontmatter contains a `title:` key.
|
||||
@@ -925,11 +998,15 @@ fn frontmatter_has_title_key(content: &str) -> bool {
|
||||
if !content.starts_with("---\n") {
|
||||
return false;
|
||||
}
|
||||
content[4..].split("\n---").next()
|
||||
.map(|fm| fm.lines().any(|l| {
|
||||
let t = l.trim_start();
|
||||
t.starts_with("title:") || t.starts_with("\"title\":")
|
||||
}))
|
||||
content[4..]
|
||||
.split("\n---")
|
||||
.next()
|
||||
.map(|fm| {
|
||||
fm.lines().any(|l| {
|
||||
let t = l.trim_start();
|
||||
t.starts_with("title:") || t.starts_with("\"title\":")
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -955,7 +1032,11 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
|
||||
}
|
||||
|
||||
/// Rename a note: update its title, rename the file, and update wiki links across the vault.
|
||||
pub fn rename_note(vault_path: &str, old_path: &str, new_title: &str) -> Result<RenameResult, String> {
|
||||
pub fn rename_note(
|
||||
vault_path: &str,
|
||||
old_path: &str,
|
||||
new_title: &str,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
|
||||
@@ -967,21 +1048,28 @@ pub fn rename_note(vault_path: &str, old_path: &str, new_title: &str) -> Result<
|
||||
return Err("New title cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(old_file)
|
||||
.map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let old_filename = old_file.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let old_filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let old_title = extract_title(&content, &old_filename);
|
||||
|
||||
if old_title == new_title {
|
||||
return Ok(RenameResult { new_path: old_path.to_string(), updated_files: 0 });
|
||||
return Ok(RenameResult {
|
||||
new_path: old_path.to_string(),
|
||||
updated_files: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Update content (H1 + frontmatter title)
|
||||
let updated_content = update_note_title_in_content(&content, new_title);
|
||||
|
||||
// Compute new path and write file
|
||||
let parent_dir = old_file.parent().ok_or("Cannot determine parent directory")?;
|
||||
let parent_dir = old_file
|
||||
.parent()
|
||||
.ok_or("Cannot determine parent directory")?;
|
||||
let new_file = parent_dir.join(format!("{}.md", title_to_slug(new_title)));
|
||||
let new_path_str = new_file.to_string_lossy().to_string();
|
||||
|
||||
@@ -996,10 +1084,17 @@ pub fn rename_note(vault_path: &str, old_path: &str, new_title: &str) -> Result<
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let updated_files = update_wikilinks_in_vault(&WikilinkReplacement {
|
||||
vault_path: vault, old_title: &old_title, new_title, old_path_stem, exclude_path: &new_file,
|
||||
vault_path: vault,
|
||||
old_title: &old_title,
|
||||
new_title,
|
||||
old_path_stem,
|
||||
exclude_path: &new_file,
|
||||
});
|
||||
|
||||
Ok(RenameResult { new_path: new_path_str, updated_files })
|
||||
Ok(RenameResult {
|
||||
new_path: new_path_str,
|
||||
updated_files,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1027,7 +1122,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_extract_title_fallback_to_filename() {
|
||||
let content = "Just some content without a heading.";
|
||||
assert_eq!(extract_title(content, "fallback-title.md"), "fallback-title");
|
||||
assert_eq!(
|
||||
extract_title(content, "fallback-title.md"),
|
||||
"fallback-title"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1073,7 +1171,11 @@ mod tests {
|
||||
#[test]
|
||||
fn test_parse_empty_frontmatter() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(&dir, "empty-fm.md", "---\n---\n# Just a Title\n\nNo frontmatter fields.");
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"empty-fm.md",
|
||||
"---\n---\n# Just a Title\n\nNo frontmatter fields.",
|
||||
);
|
||||
assert_eq!(entry.title, "Just a Title");
|
||||
assert!(entry.aliases.is_empty());
|
||||
|
||||
@@ -1106,7 +1208,11 @@ mod tests {
|
||||
fn test_scan_vault_recursive() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "root.md", "# Root Note\n");
|
||||
create_test_file(dir.path(), "sub/nested.md", "---\nIs A: Task\n---\n# Nested\n");
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"sub/nested.md",
|
||||
"---\nIs A: Task\n---\n# Nested\n",
|
||||
);
|
||||
create_test_file(dir.path(), "not-markdown.txt", "This should be ignored");
|
||||
|
||||
let entries = scan_vault(dir.path().to_str().unwrap()).unwrap();
|
||||
@@ -1219,13 +1325,33 @@ mod tests {
|
||||
let vault = dir.path();
|
||||
|
||||
// Init git repo
|
||||
std::process::Command::new("git").args(["init"]).current_dir(vault).output().unwrap();
|
||||
std::process::Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(vault).output().unwrap();
|
||||
std::process::Command::new("git").args(["config", "user.name", "Test"]).current_dir(vault).output().unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "note.md", "# Note\n\nFirst version.");
|
||||
std::process::Command::new("git").args(["add", "."]).current_dir(vault).output().unwrap();
|
||||
std::process::Command::new("git").args(["commit", "-m", "init"]).current_dir(vault).output().unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// First call: full scan, writes cache
|
||||
let entries = scan_vault_cached(vault.to_str().unwrap()).unwrap();
|
||||
@@ -1259,7 +1385,10 @@ Status: Active
|
||||
assert_eq!(entry.relationships.len(), 3); // Has, Topics, Type
|
||||
assert_eq!(
|
||||
entry.relationships.get("Has").unwrap(),
|
||||
&vec!["[[essay/foo|Foo Essay]]".to_string(), "[[essay/bar|Bar Essay]]".to_string()]
|
||||
&vec![
|
||||
"[[essay/foo|Foo Essay]]".to_string(),
|
||||
"[[essay/bar|Bar Essay]]".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
entry.relationships.get("Topics").unwrap(),
|
||||
@@ -1343,7 +1472,10 @@ Custom Field: just a plain string
|
||||
fn test_parse_relationships_owner_and_notes() {
|
||||
let rels = parse_big_project_rels();
|
||||
assert_eq!(rels.get("Notes").unwrap().len(), 3);
|
||||
assert_eq!(rels.get("Owner").unwrap(), &vec!["[[person/alice]]".to_string()]);
|
||||
assert_eq!(
|
||||
rels.get("Owner").unwrap(),
|
||||
&vec!["[[person/alice]]".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1385,7 +1517,10 @@ Context: "[[area/research]]"
|
||||
// Array → Vec with multiple elements
|
||||
assert_eq!(
|
||||
entry.relationships.get("Reviewers").unwrap(),
|
||||
&vec!["[[person/carol]]".to_string(), "[[person/dave]]".to_string()]
|
||||
&vec![
|
||||
"[[person/carol]]".to_string(),
|
||||
"[[person/dave]]".to_string()
|
||||
]
|
||||
);
|
||||
// Another single string
|
||||
assert_eq!(
|
||||
@@ -1422,7 +1557,10 @@ Context: "[[area/research]]"
|
||||
#[test]
|
||||
fn test_skip_keys_real_relation_included() {
|
||||
let (rels, len) = parse_skip_keys_rels();
|
||||
assert_eq!(rels.get("Real Relation").unwrap(), &vec!["[[note/important]]".to_string()]);
|
||||
assert_eq!(
|
||||
rels.get("Real Relation").unwrap(),
|
||||
&vec!["[[note/important]]".to_string()]
|
||||
);
|
||||
// "Real Relation" + auto-generated "Type" (from is_a: "[[type/project]]")
|
||||
assert_eq!(len, 2);
|
||||
assert_eq!(
|
||||
@@ -1451,7 +1589,10 @@ References:
|
||||
// Only the wikilink entries should be captured
|
||||
assert_eq!(
|
||||
entry.relationships.get("References").unwrap(),
|
||||
&vec!["[[source/paper-a]]".to_string(), "[[source/paper-b]]".to_string()]
|
||||
&vec![
|
||||
"[[source/paper-a]]".to_string(),
|
||||
"[[source/paper-b]]".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1508,7 +1649,10 @@ References:
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_chars_emphasis() {
|
||||
assert_eq!(strip_markdown_chars("**bold** and *italic*"), "bold and italic");
|
||||
assert_eq!(
|
||||
strip_markdown_chars("**bold** and *italic*"),
|
||||
"bold and italic"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1523,7 +1667,10 @@ References:
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_chars_link_with_url() {
|
||||
assert_eq!(strip_markdown_chars("[click here](https://example.com)"), "click here");
|
||||
assert_eq!(
|
||||
strip_markdown_chars("[click here](https://example.com)"),
|
||||
"click here"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1591,7 +1738,13 @@ References:
|
||||
for (folder, expected_type) in known_folders {
|
||||
create_test_file(dir.path(), &format!("{}/test.md", folder), "# Test\n");
|
||||
let entry = parse_md_file(&dir.path().join(folder).join("test.md")).unwrap();
|
||||
assert_eq!(entry.is_a, Some(expected_type.to_string()), "folder '{}' should infer type '{}'", folder, expected_type);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some(expected_type.to_string()),
|
||||
"folder '{}' should infer type '{}'",
|
||||
folder,
|
||||
expected_type
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1606,7 +1759,11 @@ References:
|
||||
#[test]
|
||||
fn test_infer_type_frontmatter_overrides_folder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "person/test.md", "---\nIs A: Custom\n---\n# Test\n");
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"person/test.md",
|
||||
"---\nIs A: Custom\n---\n# Test\n",
|
||||
);
|
||||
let entry = parse_md_file(&dir.path().join("person/test.md")).unwrap();
|
||||
assert_eq!(entry.is_a, Some("Custom".to_string()));
|
||||
}
|
||||
@@ -1715,13 +1872,33 @@ References:
|
||||
let vault = dir.path();
|
||||
|
||||
// Init git repo
|
||||
std::process::Command::new("git").args(["init"]).current_dir(vault).output().unwrap();
|
||||
std::process::Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(vault).output().unwrap();
|
||||
std::process::Command::new("git").args(["config", "user.name", "Test"]).current_dir(vault).output().unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "first.md", "# First\n\nFirst note.");
|
||||
std::process::Command::new("git").args(["add", "."]).current_dir(vault).output().unwrap();
|
||||
std::process::Command::new("git").args(["commit", "-m", "first"]).current_dir(vault).output().unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "first"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Build cache
|
||||
let entries = scan_vault_cached(vault.to_str().unwrap()).unwrap();
|
||||
@@ -1729,8 +1906,16 @@ References:
|
||||
|
||||
// Add a second file and commit
|
||||
create_test_file(vault, "second.md", "# Second\n\nSecond note.");
|
||||
std::process::Command::new("git").args(["add", "."]).current_dir(vault).output().unwrap();
|
||||
std::process::Command::new("git").args(["commit", "-m", "second"]).current_dir(vault).output().unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "second"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Incremental update: cache has old commit, new commit adds second.md
|
||||
let entries2 = scan_vault_cached(vault.to_str().unwrap()).unwrap();
|
||||
@@ -1886,7 +2071,10 @@ References:
|
||||
"---\nTrashed at: \"2025-01-01\"\n---\n# Old Trash\n",
|
||||
);
|
||||
// File trashed recently — should be kept
|
||||
let recent = chrono::Utc::now().date_naive().format("%Y-%m-%d").to_string();
|
||||
let recent = chrono::Utc::now()
|
||||
.date_naive()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"recent-trash.md",
|
||||
@@ -1939,14 +2127,16 @@ References:
|
||||
#[test]
|
||||
fn test_purge_trash_exactly_30_days_not_deleted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let thirty_days_ago = (chrono::Utc::now().date_naive()
|
||||
- chrono::Duration::days(30))
|
||||
let thirty_days_ago = (chrono::Utc::now().date_naive() - chrono::Duration::days(30))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"borderline.md",
|
||||
&format!("---\nTrashed at: \"{}\"\n---\n# Borderline\n", thirty_days_ago),
|
||||
&format!(
|
||||
"---\nTrashed at: \"{}\"\n---\n# Borderline\n",
|
||||
thirty_days_ago
|
||||
),
|
||||
);
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
@@ -1957,14 +2147,16 @@ References:
|
||||
#[test]
|
||||
fn test_purge_trash_31_days_deleted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let thirty_one_days_ago = (chrono::Utc::now().date_naive()
|
||||
- chrono::Duration::days(31))
|
||||
let thirty_one_days_ago = (chrono::Utc::now().date_naive() - chrono::Duration::days(31))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"expired.md",
|
||||
&format!("---\nTrashed at: \"{}\"\n---\n# Expired\n", thirty_one_days_ago),
|
||||
&format!(
|
||||
"---\nTrashed at: \"{}\"\n---\n# Expired\n",
|
||||
thirty_one_days_ago
|
||||
),
|
||||
);
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
@@ -2008,14 +2200,19 @@ References:
|
||||
fn test_rename_note_basic() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/weekly-review.md", "---\nIs A: Note\n---\n# Weekly Review\n\nContent here.\n");
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\nIs A: Note\n---\n# Weekly Review\n\nContent here.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.new_path.ends_with("sprint-retrospective.md"));
|
||||
assert!(!old_path.exists());
|
||||
@@ -2029,16 +2226,29 @@ References:
|
||||
fn test_rename_note_updates_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/weekly-review.md", "---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n");
|
||||
create_test_file(vault, "note/other.md", "---\nIs A: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n");
|
||||
create_test_file(vault, "project/my-project.md", "---\nIs A: Project\nRelated to:\n - \"[[Weekly Review]]\"\n---\n# My Project\n");
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/other.md",
|
||||
"---\nIs A: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"project/my-project.md",
|
||||
"---\nIs A: Project\nRelated to:\n - \"[[Weekly Review]]\"\n---\n# My Project\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 2);
|
||||
|
||||
@@ -2061,7 +2271,8 @@ References:
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.new_path, old_path.to_str().unwrap());
|
||||
assert_eq!(result.updated_files, 0);
|
||||
@@ -2074,11 +2285,7 @@ References:
|
||||
create_test_file(vault, "note/test.md", "# Test\n");
|
||||
|
||||
let old_path = vault.join("note/test.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
" ",
|
||||
);
|
||||
let result = rename_note(vault.to_str().unwrap(), old_path.to_str().unwrap(), " ");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -2087,14 +2294,19 @@ References:
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
|
||||
create_test_file(vault, "note/ref.md", "# Ref\n\nSee [[Weekly Review|my review]] for info.\n");
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/ref.md",
|
||||
"# Ref\n\nSee [[Weekly Review|my review]] for info.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retro",
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
|
||||
@@ -2105,14 +2317,19 @@ References:
|
||||
fn test_rename_note_updates_title_frontmatter() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/old.md", "---\ntitle: Old Name\nIs A: Note\n---\n# Old Name\n");
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/old.md",
|
||||
"---\ntitle: Old Name\nIs A: Note\n---\n# Old Name\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/old.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"New Name",
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert!(content.contains("title: New Name"));
|
||||
|
||||
@@ -46,3 +46,8 @@
|
||||
.app__editor > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useEntryActions } from './hooks/useEntryActions'
|
||||
import { isTauri } from './mock-tauri'
|
||||
import { useKeyboardNavigation } from './hooks/useKeyboardNavigation'
|
||||
import { useUpdater } from './hooks/useUpdater'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { setApiKey } from './utils/ai-chat'
|
||||
import type { SidebarSelection, GitCommit } from './types'
|
||||
import type { VaultOption } from './components/StatusBar'
|
||||
@@ -139,7 +140,7 @@ function App() {
|
||||
handleCloseTabRef: notes.handleCloseTabRef,
|
||||
})
|
||||
|
||||
useUpdater()
|
||||
const { status: updateStatus, actions: updateActions } = useUpdater()
|
||||
|
||||
useKeyboardNavigation({
|
||||
tabs: notes.tabs,
|
||||
@@ -168,6 +169,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<div className="app">
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={handleCreateNoteImmediate} onCreateNewType={openCreateTypeDialog} onCustomizeType={entryActions.handleCustomizeType} onReorderSections={entryActions.handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={() => setShowCommitDialog(true)} />
|
||||
|
||||
114
src/components/UpdateBanner.test.tsx
Normal file
114
src/components/UpdateBanner.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { UpdateBanner } from './UpdateBanner'
|
||||
import type { UpdateStatus, UpdateActions } from '../hooks/useUpdater'
|
||||
|
||||
// Mock restartApp to prevent dynamic import issues in tests
|
||||
vi.mock('../hooks/useUpdater', async () => {
|
||||
const actual = await vi.importActual('../hooks/useUpdater')
|
||||
return {
|
||||
...actual,
|
||||
restartApp: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
function makeActions(overrides?: Partial<UpdateActions>): UpdateActions {
|
||||
return {
|
||||
startDownload: vi.fn(),
|
||||
openReleaseNotes: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('UpdateBanner', () => {
|
||||
it('renders nothing when idle', () => {
|
||||
const status: UpdateStatus = { state: 'idle' }
|
||||
const { container } = render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders nothing on error state', () => {
|
||||
const status: UpdateStatus = { state: 'error' }
|
||||
const { container } = render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('shows version and action buttons when update is available', () => {
|
||||
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: 'Bug fixes' }
|
||||
const actions = makeActions()
|
||||
render(<UpdateBanner status={status} actions={actions} />)
|
||||
|
||||
expect(screen.getByTestId('update-banner')).toBeTruthy()
|
||||
expect(screen.getByText(/Laputa 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText('is available')).toBeTruthy()
|
||||
expect(screen.getByTestId('update-now-btn')).toBeTruthy()
|
||||
expect(screen.getByTestId('update-release-notes')).toBeTruthy()
|
||||
expect(screen.getByTestId('update-dismiss')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('"Update Now" calls startDownload', () => {
|
||||
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: undefined }
|
||||
const actions = makeActions()
|
||||
render(<UpdateBanner status={status} actions={actions} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-now-btn'))
|
||||
expect(actions.startDownload).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('"Release Notes" link calls openReleaseNotes', () => {
|
||||
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: undefined }
|
||||
const actions = makeActions()
|
||||
render(<UpdateBanner status={status} actions={actions} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-release-notes'))
|
||||
expect(actions.openReleaseNotes).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('dismiss button calls dismiss action', () => {
|
||||
const status: UpdateStatus = { state: 'available', version: '1.5.0', notes: undefined }
|
||||
const actions = makeActions()
|
||||
render(<UpdateBanner status={status} actions={actions} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-dismiss'))
|
||||
expect(actions.dismiss).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows progress bar during download', () => {
|
||||
const status: UpdateStatus = { state: 'downloading', version: '1.5.0', progress: 0.65 }
|
||||
render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
|
||||
expect(screen.getByText(/Downloading Laputa 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText('65%')).toBeTruthy()
|
||||
|
||||
const progressBar = screen.getByTestId('update-progress')
|
||||
expect(progressBar.style.width).toBe('65%')
|
||||
})
|
||||
|
||||
it('shows 0% at start of download', () => {
|
||||
const status: UpdateStatus = { state: 'downloading', version: '2.0.0', progress: 0 }
|
||||
render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
|
||||
expect(screen.getByText('0%')).toBeTruthy()
|
||||
const progressBar = screen.getByTestId('update-progress')
|
||||
expect(progressBar.style.width).toBe('0%')
|
||||
})
|
||||
|
||||
it('shows restart button when update is ready', () => {
|
||||
const status: UpdateStatus = { state: 'ready', version: '1.5.0' }
|
||||
render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
|
||||
expect(screen.getByText(/Laputa 1\.5\.0/)).toBeTruthy()
|
||||
expect(screen.getByText(/restart to apply/)).toBeTruthy()
|
||||
expect(screen.getByTestId('update-restart-btn')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('restart button calls restartApp', async () => {
|
||||
const { restartApp } = await import('../hooks/useUpdater')
|
||||
const status: UpdateStatus = { state: 'ready', version: '1.5.0' }
|
||||
render(<UpdateBanner status={status} actions={makeActions()} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('update-restart-btn'))
|
||||
expect(restartApp).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
145
src/components/UpdateBanner.tsx
Normal file
145
src/components/UpdateBanner.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Download, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||
import type { UpdateStatus, UpdateActions } from '../hooks/useUpdater'
|
||||
import { restartApp } from '../hooks/useUpdater'
|
||||
|
||||
interface UpdateBannerProps {
|
||||
status: UpdateStatus
|
||||
actions: UpdateActions
|
||||
}
|
||||
|
||||
export function UpdateBanner({ status, actions }: UpdateBannerProps) {
|
||||
if (status.state === 'idle' || status.state === 'error') return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="update-banner"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '6px 12px',
|
||||
background: 'var(--accent-blue, #E8F0FE)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontSize: 13,
|
||||
color: 'var(--foreground)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{status.state === 'available' && (
|
||||
<>
|
||||
<Download size={14} style={{ color: 'var(--primary)', flexShrink: 0 }} />
|
||||
<span>
|
||||
<strong>Laputa {status.version}</strong> is available
|
||||
</span>
|
||||
<button
|
||||
data-testid="update-release-notes"
|
||||
onClick={actions.openReleaseNotes}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--primary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
padding: 0,
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Release Notes <ExternalLink size={11} />
|
||||
</button>
|
||||
<button
|
||||
data-testid="update-now-btn"
|
||||
onClick={actions.startDownload}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: '3px 10px',
|
||||
background: 'var(--primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 5,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Update Now
|
||||
</button>
|
||||
<button
|
||||
data-testid="update-dismiss"
|
||||
onClick={actions.dismiss}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--muted-foreground)',
|
||||
display: 'flex',
|
||||
padding: 2,
|
||||
}}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status.state === 'downloading' && (
|
||||
<>
|
||||
<RefreshCw size={14} style={{ color: 'var(--primary)', flexShrink: 0, animation: 'spin 1s linear infinite' }} />
|
||||
<span>Downloading Laputa {status.version}...</span>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: 200,
|
||||
height: 4,
|
||||
background: 'var(--border)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="update-progress"
|
||||
style={{
|
||||
width: `${Math.round(status.progress * 100)}%`,
|
||||
height: '100%',
|
||||
background: 'var(--primary)',
|
||||
borderRadius: 2,
|
||||
transition: 'width 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>
|
||||
{Math.round(status.progress * 100)}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status.state === 'ready' && (
|
||||
<>
|
||||
<RefreshCw size={14} style={{ color: 'var(--accent-green, #0F7B0F)', flexShrink: 0 }} />
|
||||
<span>
|
||||
<strong>Laputa {status.version}</strong> is ready — restart to apply
|
||||
</span>
|
||||
<button
|
||||
data-testid="update-restart-btn"
|
||||
onClick={restartApp}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: '3px 10px',
|
||||
background: 'var(--accent-green, #0F7B0F)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 5,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Restart Now
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { useUpdater } from './useUpdater'
|
||||
|
||||
@@ -25,7 +25,6 @@ describe('useUpdater', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
@@ -34,74 +33,166 @@ describe('useUpdater', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does nothing when not in Tauri', () => {
|
||||
it('starts in idle state', () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('does nothing when not in Tauri', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
renderHook(() => useUpdater())
|
||||
vi.advanceTimersByTime(5000)
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(mockCheck).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks for updates after delay when in Tauri', async () => {
|
||||
it('checks for updates after 3s delay when in Tauri', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockResolvedValue(null) // no update
|
||||
|
||||
renderHook(() => useUpdater())
|
||||
expect(mockCheck).not.toHaveBeenCalled()
|
||||
|
||||
// Advance past the 3s delay, then flush microtasks for dynamic imports
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
// Dynamic imports are resolved by the mock, but need microtask flush
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCheck).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirm dialog when update is available', async () => {
|
||||
it('stays idle when no update is available', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockResolvedValue(null)
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCheck).toHaveBeenCalled()
|
||||
})
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('transitions to available when update is found', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockResolvedValue({
|
||||
version: '1.2.0',
|
||||
body: 'Bug fixes and improvements',
|
||||
downloadAndInstall: vi.fn().mockResolvedValue(undefined),
|
||||
downloadAndInstall: vi.fn(),
|
||||
})
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
|
||||
renderHook(() => useUpdater())
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('1.2.0')
|
||||
)
|
||||
expect(mockRelaunch).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.status).toEqual({
|
||||
state: 'available',
|
||||
version: '1.2.0',
|
||||
notes: 'Bug fixes and improvements',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('downloads, installs, and relaunches when user accepts', async () => {
|
||||
it('handles missing body gracefully', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
const mockDownloadAndInstall = vi.fn().mockResolvedValue(undefined)
|
||||
mockCheck.mockResolvedValue({
|
||||
version: '1.2.0',
|
||||
body: '',
|
||||
downloadAndInstall: mockDownloadAndInstall,
|
||||
version: '2.0.0',
|
||||
body: null,
|
||||
downloadAndInstall: vi.fn(),
|
||||
})
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
mockRelaunch.mockResolvedValue(undefined)
|
||||
|
||||
renderHook(() => useUpdater())
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
|
||||
expect(mockDownloadAndInstall).toHaveBeenCalled()
|
||||
expect(mockRelaunch).toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.status).toEqual({
|
||||
state: 'available',
|
||||
version: '2.0.0',
|
||||
notes: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('logs warning on check failure without crashing', async () => {
|
||||
it('stays idle on network error (fails silently)', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
renderHook(() => useUpdater())
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'[updater] Failed to check for updates:',
|
||||
expect.any(Error)
|
||||
await vi.waitFor(() => {
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'[updater] Failed to check for updates'
|
||||
)
|
||||
})
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('dismiss returns to idle from available', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockCheck.mockResolvedValue({
|
||||
version: '1.2.0',
|
||||
body: 'Notes',
|
||||
downloadAndInstall: vi.fn(),
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.status.state).toBe('available')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.actions.dismiss()
|
||||
})
|
||||
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('openReleaseNotes opens the release notes URL', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
|
||||
act(() => {
|
||||
result.current.actions.openReleaseNotes()
|
||||
})
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://refactoringhq.github.io/laputa-app/',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('startDownload transitions through downloading to ready', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
|
||||
const mockDownload = vi.fn(async (callback: (event: { event: string; data?: Record<string, unknown> }) => void) => {
|
||||
callback({ event: 'Started', data: { contentLength: 1000 } })
|
||||
callback({ event: 'Progress', data: { chunkLength: 500 } })
|
||||
callback({ event: 'Progress', data: { chunkLength: 500 } })
|
||||
callback({ event: 'Finished' })
|
||||
})
|
||||
|
||||
mockCheck.mockResolvedValue({
|
||||
version: '1.2.0',
|
||||
body: 'Notes',
|
||||
downloadAndInstall: mockDownload,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
await vi.advanceTimersByTimeAsync(3500)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(result.current.status.state).toBe('available')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.actions.startDownload()
|
||||
})
|
||||
|
||||
expect(result.current.status).toEqual({ state: 'ready', version: '1.2.0' })
|
||||
expect(mockDownload).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,40 +1,104 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
/**
|
||||
* Checks for OTA updates on app startup (Tauri only).
|
||||
* If an update is available, shows a native confirm dialog and
|
||||
* downloads + installs + relaunches if the user accepts.
|
||||
*/
|
||||
export function useUpdater() {
|
||||
const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/laputa-app/'
|
||||
|
||||
export type UpdateStatus =
|
||||
| { state: 'idle' }
|
||||
| { state: 'available'; version: string; notes: string | undefined }
|
||||
| { state: 'downloading'; version: string; progress: number }
|
||||
| { state: 'ready'; version: string }
|
||||
| { state: 'error' }
|
||||
|
||||
export interface UpdateActions {
|
||||
startDownload: () => void
|
||||
openReleaseNotes: () => void
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
|
||||
const [status, setStatus] = useState<UpdateStatus>({ state: 'idle' })
|
||||
const updateRef = useRef<unknown>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater')
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process')
|
||||
|
||||
const update = await check()
|
||||
if (!update) return
|
||||
if (!update) return // up to date
|
||||
|
||||
const yes = window.confirm(
|
||||
`A new version (${update.version}) is available.\n\n` +
|
||||
(update.body ? `${update.body}\n\n` : '') +
|
||||
'Do you want to update and restart now?'
|
||||
)
|
||||
if (!yes) return
|
||||
|
||||
await update.downloadAndInstall()
|
||||
await relaunch()
|
||||
} catch (err) {
|
||||
// Silently log — update check failures should never block the app
|
||||
console.warn('[updater] Failed to check for updates:', err)
|
||||
updateRef.current = update
|
||||
setStatus({
|
||||
state: 'available',
|
||||
version: update.version,
|
||||
notes: update.body ?? undefined,
|
||||
})
|
||||
} catch {
|
||||
// Network error or 404 — fail silently
|
||||
console.warn('[updater] Failed to check for updates')
|
||||
}
|
||||
}
|
||||
|
||||
// Delay slightly so the app can render first
|
||||
// Delay so the app can render first
|
||||
const timer = setTimeout(checkForUpdates, 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
const startDownload = useCallback(async () => {
|
||||
const update = updateRef.current as {
|
||||
version: string
|
||||
downloadAndInstall: (cb: (event: { event: string; data?: { contentLength?: number; chunkLength?: number } }) => void) => Promise<void>
|
||||
} | null
|
||||
if (!update) return
|
||||
|
||||
let totalBytes = 0
|
||||
let downloadedBytes = 0
|
||||
|
||||
setStatus({ state: 'downloading', version: update.version, progress: 0 })
|
||||
|
||||
try {
|
||||
await update.downloadAndInstall((event) => {
|
||||
if (event.event === 'Started' && event.data?.contentLength) {
|
||||
totalBytes = event.data.contentLength
|
||||
} else if (event.event === 'Progress' && event.data?.chunkLength) {
|
||||
downloadedBytes += event.data.chunkLength
|
||||
const progress = totalBytes > 0 ? Math.min(downloadedBytes / totalBytes, 1) : 0
|
||||
setStatus({ state: 'downloading', version: update.version, progress })
|
||||
} else if (event.event === 'Finished') {
|
||||
setStatus({ state: 'ready', version: update.version })
|
||||
}
|
||||
})
|
||||
|
||||
// If Finished wasn't emitted via callback, set ready after await resolves
|
||||
setStatus((prev) => (prev.state === 'downloading' ? { state: 'ready', version: update.version } : prev))
|
||||
} catch {
|
||||
console.warn('[updater] Download failed')
|
||||
setStatus({ state: 'error' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openReleaseNotes = useCallback(() => {
|
||||
window.open(RELEASE_NOTES_URL, '_blank')
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setStatus({ state: 'idle' })
|
||||
}, [])
|
||||
|
||||
return { status, actions: { startDownload, openReleaseNotes, dismiss } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger app restart after an update has been downloaded.
|
||||
* Separated so the component can call it on button click.
|
||||
*/
|
||||
export async function restartApp(): Promise<void> {
|
||||
try {
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process')
|
||||
await relaunch()
|
||||
} catch {
|
||||
console.warn('[updater] Failed to relaunch')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user