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:
Luca Rossi
2026-02-23 11:50:36 +01:00
committed by GitHub
parent efd79e9a3d
commit 5ebcf41d47
19 changed files with 16082 additions and 323 deletions

View File

@@ -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, '&lt;').replace(/>/g, '&gt;')
.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

File diff suppressed because it is too large Load Diff

View File

@@ -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.

View File

@@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
tauri_build::build()
}

View File

@@ -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(),

View File

@@ -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"));

View File

@@ -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"));

View File

@@ -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(),

View File

@@ -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)?;
}

View File

@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
laputa_lib::run();
laputa_lib::run();
}

View File

@@ -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)?;

View File

@@ -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]

View File

@@ -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, &current_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: &regex::Captures| {
match caps.get(1) {
Some(pipe) => format!("[[{}{}]]", new_title, pipe.as_str()),
None => format!("[[{}]]", new_title),
}
let replaced = re.replace_all(content, |caps: &regex::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"));

View File

@@ -46,3 +46,8 @@
.app__editor > * {
flex: 1;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -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)} />

View 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()
})
})

View 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>
)
}

View File

@@ -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()
})
})

View File

@@ -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')
}
}