Compare commits
22 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9d94abae4 | ||
|
|
18b2aaedf6 | ||
|
|
1706300494 | ||
|
|
628ab76f09 | ||
|
|
d3896ddf01 | ||
|
|
7289a60db3 | ||
|
|
0cff626e48 | ||
|
|
5a4c986fe3 | ||
|
|
540b1400e2 | ||
|
|
826cda852a | ||
|
|
19583ea1f5 | ||
|
|
63eb4ff980 | ||
|
|
bbb29857b8 | ||
|
|
900ce7f66f | ||
|
|
eb55c5ec02 | ||
|
|
6f6e7d7cfe | ||
|
|
edcb306c7f | ||
|
|
97be1d1ca3 | ||
|
|
ea29a81d79 | ||
|
|
5b1fda2279 | ||
|
|
1cd596061a | ||
|
|
efb233b18f |
@@ -1 +1 @@
|
||||
45402
|
||||
33549
|
||||
|
||||
9
.codescenerc
Normal file
9
.codescenerc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"exclude": [
|
||||
"tools/",
|
||||
"scripts/",
|
||||
"src-tauri/gen/",
|
||||
"coverage/",
|
||||
"dist/"
|
||||
]
|
||||
}
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -176,7 +176,7 @@ jobs:
|
||||
REPO="refactoringhq/laputa-app"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
|
||||
cat > latest.json << EOF
|
||||
{
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
"signature": "${ARM_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}"
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,26 +47,26 @@ fi
|
||||
|
||||
# ── 0. TypeScript + Vite build ──────────────────────────────────────────
|
||||
echo ""
|
||||
echo "📦 [0/4] TypeScript + Vite build..."
|
||||
echo "📦 [0/5] TypeScript + Vite build..."
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm build
|
||||
echo " ✅ Build OK"
|
||||
|
||||
# ── 1. Frontend coverage (≥70%) — includes all unit tests ───────────────
|
||||
echo ""
|
||||
echo "📊 [1/4] Frontend tests + coverage (≥70%)..."
|
||||
echo "📊 [1/5] Frontend tests + coverage (≥70%)..."
|
||||
pnpm test:coverage --silent
|
||||
echo " ✅ Frontend coverage OK"
|
||||
|
||||
# ── 2. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
|
||||
echo ""
|
||||
if [ "$RUST_CHANGED" = true ]; then
|
||||
echo "🔧 [2/4] Clippy + rustfmt..."
|
||||
echo "🔧 [2/5] Clippy + rustfmt..."
|
||||
cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
|
||||
cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
|
||||
echo " ✅ Rust lint OK"
|
||||
else
|
||||
echo "⏭️ [2/4] Rust lint — skipped (no src-tauri/ changes)"
|
||||
echo "⏭️ [2/5] Rust lint — skipped (no src-tauri/ changes)"
|
||||
fi
|
||||
|
||||
# ── 3. Rust coverage (≥85% lines) ──────────────────────────────────────
|
||||
@@ -75,9 +75,9 @@ if [ "$RUST_CHANGED" = true ]; then
|
||||
LLVM_COV_FLAGS="--no-clean"
|
||||
if [ "${LAPUTA_FULL_COVERAGE:-0}" = "1" ]; then
|
||||
LLVM_COV_FLAGS=""
|
||||
echo "🦀 [3/4] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
|
||||
echo "🦀 [3/5] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
|
||||
else
|
||||
echo "🦀 [3/4] Rust coverage (≥85%, incremental)..."
|
||||
echo "🦀 [3/5] Rust coverage (≥85%, incremental)..."
|
||||
fi
|
||||
# Unset GIT_DIR so git tests create isolated repos without inheriting hook context
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
|
||||
@@ -90,12 +90,23 @@ if [ "$RUST_CHANGED" = true ]; then
|
||||
-- --test-threads=1
|
||||
echo " ✅ Rust coverage OK"
|
||||
else
|
||||
echo "⏭️ [3/4] Rust coverage — skipped (no src-tauri/ changes)"
|
||||
echo "⏭️ [3/5] Rust coverage — skipped (no src-tauri/ changes)"
|
||||
fi
|
||||
|
||||
# ── 4. CodeScene code health gate (≥9.2) ────────────────────────────────
|
||||
# ── 4. Playwright smoke tests (if any exist) ──────────────────────────
|
||||
echo ""
|
||||
echo "🏥 [4/4] CodeScene code health gate (≥9.2)..."
|
||||
SMOKE_FILES=$(find tests/smoke -name '*.spec.ts' 2>/dev/null | head -1)
|
||||
if [ -n "$SMOKE_FILES" ]; then
|
||||
echo "🎭 [4/5] Playwright smoke tests..."
|
||||
pnpm playwright:smoke
|
||||
echo " ✅ Smoke tests OK"
|
||||
else
|
||||
echo "⏭️ [4/5] Playwright smoke tests — skipped (no tests/smoke/*.spec.ts)"
|
||||
fi
|
||||
|
||||
# ── 5. CodeScene code health gate (≥9.2) ────────────────────────────────
|
||||
echo ""
|
||||
echo "🏥 [5/5] CodeScene code health gate (≥9.2)..."
|
||||
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
|
||||
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping"
|
||||
else
|
||||
|
||||
@@ -28,10 +28,10 @@ DEV_PID=$!
|
||||
sleep 3 # wait for vite to be ready
|
||||
|
||||
# 2. Run Playwright smoke test for this task
|
||||
npx playwright test --headed=false tests/smoke/<slug>.spec.ts
|
||||
BASE_URL="http://localhost:<N>" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
|
||||
# 3. Or use the MCP Playwright tool directly in your session to drive the browser
|
||||
# e.g. navigate to localhost:<N>, open Cmd+K, verify command appears, etc.
|
||||
# 3. Or run all smoke tests
|
||||
BASE_URL="http://localhost:<N>" pnpm playwright:smoke
|
||||
|
||||
kill $DEV_PID
|
||||
```
|
||||
|
||||
3
demo-vault-v2/.laputa/settings.json
Normal file
3
demo-vault-v2/.laputa/settings.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"theme": null
|
||||
}
|
||||
33
demo-vault-v2/_themes/dark.json
Normal file
33
demo-vault-v2/_themes/dark.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "Dark",
|
||||
"description": "Dark variant with deep navy tones",
|
||||
"colors": {
|
||||
"background": "#0f0f1a",
|
||||
"foreground": "#e0e0e0",
|
||||
"card": "#16162a",
|
||||
"popover": "#1e1e3a",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"secondary": "#2a2a4a",
|
||||
"secondary-foreground": "#e0e0e0",
|
||||
"muted": "#1e1e3a",
|
||||
"muted-foreground": "#888888",
|
||||
"accent": "#2a2a4a",
|
||||
"accent-foreground": "#e0e0e0",
|
||||
"destructive": "#f44336",
|
||||
"border": "#2a2a4a",
|
||||
"input": "#2a2a4a",
|
||||
"ring": "#155DFF",
|
||||
"sidebar-background": "#1a1a2e",
|
||||
"sidebar-foreground": "#e0e0e0",
|
||||
"sidebar-border": "#2a2a4a",
|
||||
"sidebar-accent": "#2a2a4a"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
},
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/default.json
Normal file
33
demo-vault-v2/_themes/default.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "Default",
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"colors": {
|
||||
"background": "#FFFFFF",
|
||||
"foreground": "#37352F",
|
||||
"card": "#FFFFFF",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"destructive": "#E03E3E",
|
||||
"border": "#E9E9E7",
|
||||
"input": "#E9E9E7",
|
||||
"ring": "#155DFF",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-foreground": "#37352F",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-accent": "#EBEBEA"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
},
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/minimal.json
Normal file
33
demo-vault-v2/_themes/minimal.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "Minimal",
|
||||
"description": "High contrast, minimal chrome",
|
||||
"colors": {
|
||||
"background": "#FAFAFA",
|
||||
"foreground": "#111111",
|
||||
"card": "#FFFFFF",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#000000",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"secondary": "#F0F0F0",
|
||||
"secondary-foreground": "#111111",
|
||||
"muted": "#F5F5F5",
|
||||
"muted-foreground": "#666666",
|
||||
"accent": "#F0F0F0",
|
||||
"accent-foreground": "#111111",
|
||||
"destructive": "#CC0000",
|
||||
"border": "#E0E0E0",
|
||||
"input": "#E0E0E0",
|
||||
"ring": "#000000",
|
||||
"sidebar-background": "#F5F5F5",
|
||||
"sidebar-foreground": "#111111",
|
||||
"sidebar-border": "#E0E0E0",
|
||||
"sidebar-accent": "#E8E8E8"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'SF Mono', 'Menlo', monospace",
|
||||
"font-size-base": "13px"
|
||||
},
|
||||
"spacing": {
|
||||
"sidebar-width": "220px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled-2.json
Normal file
33
demo-vault-v2/_themes/untitled-2.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"colors": {
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E9E9E7",
|
||||
"card": "#FFFFFF",
|
||||
"destructive": "#E03E3E",
|
||||
"foreground": "#37352F",
|
||||
"input": "#E9E9E7",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"ring": "#155DFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"sidebar-accent": "#EBEBEA",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-foreground": "#37352F"
|
||||
},
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"name": "Untitled Theme",
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled-3.json
Normal file
33
demo-vault-v2/_themes/untitled-3.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"colors": {
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E9E9E7",
|
||||
"card": "#FFFFFF",
|
||||
"destructive": "#E03E3E",
|
||||
"foreground": "#37352F",
|
||||
"input": "#E9E9E7",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"ring": "#155DFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"sidebar-accent": "#EBEBEA",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-foreground": "#37352F"
|
||||
},
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"name": "Untitled Theme",
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled-4.json
Normal file
33
demo-vault-v2/_themes/untitled-4.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"colors": {
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E9E9E7",
|
||||
"card": "#FFFFFF",
|
||||
"destructive": "#E03E3E",
|
||||
"foreground": "#37352F",
|
||||
"input": "#E9E9E7",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"ring": "#155DFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"sidebar-accent": "#EBEBEA",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-foreground": "#37352F"
|
||||
},
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"name": "Untitled Theme",
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled.json
Normal file
33
demo-vault-v2/_themes/untitled.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"colors": {
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E9E9E7",
|
||||
"card": "#FFFFFF",
|
||||
"destructive": "#E03E3E",
|
||||
"foreground": "#37352F",
|
||||
"input": "#E9E9E7",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"ring": "#155DFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"sidebar-accent": "#EBEBEA",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-foreground": "#37352F"
|
||||
},
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"name": "Untitled Theme",
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
}
|
||||
}
|
||||
5
demo-vault-v2/config/ui.config.md
Normal file
5
demo-vault-v2/config/ui.config.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: config
|
||||
zoom: 1.3
|
||||
view_mode: all
|
||||
---
|
||||
@@ -4,6 +4,8 @@ Is A: Note
|
||||
Author: "Clayton Christensen"
|
||||
Topics: ["[[topic-saas-business]]"]
|
||||
URL: "https://example.com/innovators-dilemma"
|
||||
trashed: true
|
||||
trashed_at: 2026-03-04
|
||||
---
|
||||
# The Innovator's Dilemma
|
||||
*Clayton Christensen*
|
||||
|
||||
5
demo-vault-v2/note/wikilinks-qa-test.md
Normal file
5
demo-vault-v2/note/wikilinks-qa-test.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Wikilinks QA Test
|
||||
|
||||
See [[ProjectX]] and [[Team Goals|Goals]] for details on the project timeline.
|
||||
|
||||
This is a test note for QA purposes.
|
||||
54
demo-vault-v2/theme/dark.md
Normal file
54
demo-vault-v2/theme/dark.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
Is A: Theme
|
||||
Description: Dark variant with deep navy tones
|
||||
background: "#0f0f1a"
|
||||
foreground: "#e0e0e0"
|
||||
card: "#16162a"
|
||||
popover: "#1e1e3a"
|
||||
primary: "#155DFF"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#2a2a4a"
|
||||
secondary-foreground: "#e0e0e0"
|
||||
muted: "#1e1e3a"
|
||||
muted-foreground: "#888888"
|
||||
accent: "#2a2a4a"
|
||||
accent-foreground: "#e0e0e0"
|
||||
destructive: "#f44336"
|
||||
border: "#2a2a4a"
|
||||
input: "#2a2a4a"
|
||||
ring: "#155DFF"
|
||||
sidebar: "#1a1a2e"
|
||||
sidebar-foreground: "#e0e0e0"
|
||||
sidebar-border: "#2a2a4a"
|
||||
sidebar-accent: "#2a2a4a"
|
||||
text-primary: "#e0e0e0"
|
||||
text-secondary: "#888888"
|
||||
text-muted: "#666666"
|
||||
text-heading: "#e0e0e0"
|
||||
bg-primary: "#0f0f1a"
|
||||
bg-sidebar: "#1a1a2e"
|
||||
bg-hover: "#2a2a4a"
|
||||
bg-hover-subtle: "#1e1e3a"
|
||||
bg-selected: "#155DFF22"
|
||||
border-primary: "#2a2a4a"
|
||||
accent-blue: "#155DFF"
|
||||
accent-green: "#00B38B"
|
||||
accent-orange: "#D9730D"
|
||||
accent-red: "#f44336"
|
||||
accent-purple: "#A932FF"
|
||||
accent-yellow: "#F0B100"
|
||||
accent-blue-light: "#155DFF33"
|
||||
accent-green-light: "#00B38B33"
|
||||
accent-purple-light: "#A932FF33"
|
||||
accent-red-light: "#f4433633"
|
||||
accent-yellow-light: "#F0B10033"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14px
|
||||
editor-font-size: 16
|
||||
editor-line-height: 1.5
|
||||
editor-max-width: 720
|
||||
---
|
||||
|
||||
# Dark Theme
|
||||
|
||||
A dark theme with deep navy tones for comfortable night-time reading.
|
||||
54
demo-vault-v2/theme/default.md
Normal file
54
demo-vault-v2/theme/default.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
Is A: Theme
|
||||
Description: Light theme with warm, paper-like tones
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
card: "#FFFFFF"
|
||||
popover: "#FFFFFF"
|
||||
primary: "#155DFF"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#EBEBEA"
|
||||
secondary-foreground: "#37352F"
|
||||
muted: "#F0F0EF"
|
||||
muted-foreground: "#787774"
|
||||
accent: "#EBEBEA"
|
||||
accent-foreground: "#37352F"
|
||||
destructive: "#E03E3E"
|
||||
border: "#E9E9E7"
|
||||
input: "#E9E9E7"
|
||||
ring: "#155DFF"
|
||||
sidebar: "#F7F6F3"
|
||||
sidebar-foreground: "#37352F"
|
||||
sidebar-border: "#E9E9E7"
|
||||
sidebar-accent: "#EBEBEA"
|
||||
text-primary: "#37352F"
|
||||
text-secondary: "#787774"
|
||||
text-muted: "#B4B4B4"
|
||||
text-heading: "#37352F"
|
||||
bg-primary: "#FFFFFF"
|
||||
bg-sidebar: "#F7F6F3"
|
||||
bg-hover: "#EBEBEA"
|
||||
bg-hover-subtle: "#F0F0EF"
|
||||
bg-selected: "#E8F4FE"
|
||||
border-primary: "#E9E9E7"
|
||||
accent-blue: "#155DFF"
|
||||
accent-green: "#00B38B"
|
||||
accent-orange: "#D9730D"
|
||||
accent-red: "#E03E3E"
|
||||
accent-purple: "#A932FF"
|
||||
accent-yellow: "#F0B100"
|
||||
accent-blue-light: "#155DFF14"
|
||||
accent-green-light: "#00B38B14"
|
||||
accent-purple-light: "#A932FF14"
|
||||
accent-red-light: "#E03E3E14"
|
||||
accent-yellow-light: "#F0B10014"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14px
|
||||
editor-font-size: 16
|
||||
editor-line-height: 1.5
|
||||
editor-max-width: 720
|
||||
---
|
||||
|
||||
# Default Theme
|
||||
|
||||
The default light theme for Laputa. Clean and warm, inspired by Notion.
|
||||
54
demo-vault-v2/theme/minimal.md
Normal file
54
demo-vault-v2/theme/minimal.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
Is A: Theme
|
||||
Description: High contrast, minimal chrome
|
||||
background: "#FAFAFA"
|
||||
foreground: "#111111"
|
||||
card: "#FFFFFF"
|
||||
popover: "#FFFFFF"
|
||||
primary: "#000000"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#F0F0F0"
|
||||
secondary-foreground: "#111111"
|
||||
muted: "#F5F5F5"
|
||||
muted-foreground: "#666666"
|
||||
accent: "#F0F0F0"
|
||||
accent-foreground: "#111111"
|
||||
destructive: "#CC0000"
|
||||
border: "#E0E0E0"
|
||||
input: "#E0E0E0"
|
||||
ring: "#000000"
|
||||
sidebar: "#F5F5F5"
|
||||
sidebar-foreground: "#111111"
|
||||
sidebar-border: "#E0E0E0"
|
||||
sidebar-accent: "#E8E8E8"
|
||||
text-primary: "#111111"
|
||||
text-secondary: "#666666"
|
||||
text-muted: "#999999"
|
||||
text-heading: "#111111"
|
||||
bg-primary: "#FAFAFA"
|
||||
bg-sidebar: "#F5F5F5"
|
||||
bg-hover: "#EBEBEB"
|
||||
bg-hover-subtle: "#F5F5F5"
|
||||
bg-selected: "#00000014"
|
||||
border-primary: "#E0E0E0"
|
||||
accent-blue: "#000000"
|
||||
accent-green: "#006600"
|
||||
accent-orange: "#996600"
|
||||
accent-red: "#CC0000"
|
||||
accent-purple: "#660099"
|
||||
accent-yellow: "#996600"
|
||||
accent-blue-light: "#00000014"
|
||||
accent-green-light: "#00660014"
|
||||
accent-purple-light: "#66009914"
|
||||
accent-red-light: "#CC000014"
|
||||
accent-yellow-light: "#99660014"
|
||||
font-family: "'SF Mono', 'Menlo', monospace"
|
||||
font-size-base: 13px
|
||||
editor-font-size: 15
|
||||
editor-line-height: 1.6
|
||||
editor-max-width: 680
|
||||
---
|
||||
|
||||
# Minimal Theme
|
||||
|
||||
High contrast, minimal chrome. Monospace typography throughout.
|
||||
@@ -13,6 +13,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test tests/smoke/",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepare": "husky"
|
||||
},
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30000,
|
||||
testDir: './tests/smoke',
|
||||
timeout: 15_000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:5201',
|
||||
headless: true,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
port: 5173,
|
||||
command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5201'}`,
|
||||
url: process.env.BASE_URL || 'http://localhost:5201',
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
})
|
||||
|
||||
661
src-tauri/src/commands.rs
Normal file
661
src-tauri/src/commands.rs
Normal file
@@ -0,0 +1,661 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::ai_chat::{AiChatRequest, AiChatResponse};
|
||||
use crate::claude_cli::{
|
||||
AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent,
|
||||
};
|
||||
use crate::frontmatter::FrontmatterValue;
|
||||
use crate::git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
|
||||
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
use crate::indexing::{IndexStatus, IndexingProgress};
|
||||
use crate::search::SearchResponse;
|
||||
use crate::settings::Settings;
|
||||
use crate::theme::{ThemeFile, VaultSettings};
|
||||
use crate::vault::{RenameResult, VaultEntry};
|
||||
use crate::vault_config::VaultConfig;
|
||||
use crate::vault_list::VaultList;
|
||||
use crate::{
|
||||
frontmatter, git, github, indexing, menu, search, theme, vault, vault_config, vault_list,
|
||||
};
|
||||
|
||||
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
|
||||
/// Returns the original string unchanged if it doesn't start with `~` or if the
|
||||
/// home directory cannot be determined.
|
||||
pub fn expand_tilde(path: &str) -> Cow<'_, str> {
|
||||
if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(home.to_string_lossy().into_owned());
|
||||
}
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest));
|
||||
}
|
||||
}
|
||||
Cow::Borrowed(path)
|
||||
}
|
||||
|
||||
pub fn parse_build_label(version: &str) -> String {
|
||||
let parts: Vec<&str> = version.split('.').collect();
|
||||
match parts.as_slice() {
|
||||
[_, minor, patch] if minor.len() >= 6 => format!("b{}", patch),
|
||||
[_, _, _] => "dev".to_string(),
|
||||
_ => "b?".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit_unavailable(app_handle: &tauri::AppHandle) {
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"indexing-progress",
|
||||
IndexingProgress {
|
||||
phase: "unavailable".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: true,
|
||||
error: Some("qmd not available".to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vault commands ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_note_content(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::get_note_content(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_note_content(path: String, content: String) -> Result<(), String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::save_note_content(&path, &content)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_note(
|
||||
vault_path: String,
|
||||
old_path: String,
|
||||
new_title: String,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let old_path = expand_tilde(&old_path);
|
||||
vault::rename_note(&vault_path, &old_path, &new_title)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::purge_trash(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_note(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::delete_note(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::migrate_is_a_to_type(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
|
||||
let path = match target_path {
|
||||
Some(p) if !p.is_empty() => expand_tilde(&p).into_owned(),
|
||||
_ => vault::default_vault_path()?.to_string_lossy().to_string(),
|
||||
};
|
||||
vault::create_getting_started_vault(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_vault_exists(path: String) -> bool {
|
||||
let path = expand_tilde(&path);
|
||||
vault::vault_exists(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_default_vault_path() -> Result<String, String> {
|
||||
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::save_image(&vault_path, &filename, &data)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::copy_image_to_vault(&vault_path, &source_path)
|
||||
}
|
||||
|
||||
// ── Frontmatter commands ────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_frontmatter(
|
||||
path: String,
|
||||
key: String,
|
||||
value: FrontmatterValue,
|
||||
) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
frontmatter::update_frontmatter(&path, &key, value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
frontmatter::delete_frontmatter_property(&path, &key)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(
|
||||
&path,
|
||||
"Trashed at",
|
||||
FrontmatterValue::String(now.clone()),
|
||||
)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
// ── Git commands ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_history(&vault_path, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_modified_files(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_diff(&vault_path, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff_at_commit(
|
||||
vault_path: String,
|
||||
path: String,
|
||||
commit_hash: String,
|
||||
) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_vault_pulse(
|
||||
vault_path: String,
|
||||
limit: Option<usize>,
|
||||
skip: Option<usize>,
|
||||
) -> Result<Vec<PulseCommit>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let limit = limit.unwrap_or(20);
|
||||
let skip = skip.unwrap_or(0);
|
||||
git::get_vault_pulse(&vault_path, limit, skip)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_commit(vault_path: String, message: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_commit(&vault_path, &message)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_last_commit_info(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_pull(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_conflict_files(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_mode(vault_path: String) -> String {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_conflict_mode(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_resolve_conflict(
|
||||
vault_path: String,
|
||||
file: String,
|
||||
strategy: String,
|
||||
) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_resolve_conflict(&vault_path, &file, &strategy)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_commit_conflict_resolution(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_push(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_push(&vault_path)
|
||||
}
|
||||
|
||||
// ── GitHub commands ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
github::github_list_repos(&token).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_create_repo(
|
||||
token: String,
|
||||
name: String,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
github::github_create_repo(&token, &name, private).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
|
||||
let local_path = expand_tilde(&local_path);
|
||||
github::clone_repo(&url, &token, &local_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
github::github_device_flow_start().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
|
||||
github::github_device_flow_poll(&device_code).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_get_user(token: String) -> Result<GitHubUser, String> {
|
||||
github::github_get_user(&token).await
|
||||
}
|
||||
|
||||
// ── AI / Claude commands ────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
|
||||
crate::ai_chat::send_chat(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_claude_cli() -> ClaudeCliStatus {
|
||||
crate::claude_cli::check_cli()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stream_claude_chat(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: ChatStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stream_claude_agent(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: AgentStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-agent-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
// ── Search & indexing commands ──────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_vault(
|
||||
vault_path: String,
|
||||
query: String,
|
||||
mode: String,
|
||||
limit: Option<usize>,
|
||||
) -> Result<SearchResponse, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
let limit = limit.unwrap_or(20);
|
||||
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
|
||||
.await
|
||||
.map_err(|e| format!("Search task failed: {}", e))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_index_status(vault_path: String) -> IndexStatus {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
indexing::check_index_status(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_indexing(
|
||||
app_handle: tauri::AppHandle,
|
||||
vault_path: String,
|
||||
) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if indexing::find_qmd_binary().is_none() {
|
||||
log::info!("qmd binary not found — attempting auto-install via bun");
|
||||
let _ = app_handle.emit(
|
||||
"indexing-progress",
|
||||
IndexingProgress {
|
||||
phase: "installing".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: false,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
match indexing::try_auto_install_qmd() {
|
||||
Ok(()) if indexing::find_qmd_binary().is_some() => {
|
||||
log::info!("qmd auto-installed successfully, proceeding with indexing");
|
||||
}
|
||||
Ok(()) => {
|
||||
log::warn!("qmd auto-install reported success but binary still not found");
|
||||
emit_unavailable(&app_handle);
|
||||
return Err("qmd not available after install".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
log::info!("qmd auto-install failed: {e}");
|
||||
emit_unavailable(&app_handle);
|
||||
return Err(format!("qmd not available: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indexing::run_full_index(&vault_path, |progress| {
|
||||
let _ = app_handle.emit("indexing-progress", &progress);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Indexing task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn trigger_incremental_index(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Incremental index failed: {e}"))?
|
||||
}
|
||||
|
||||
// ── MCP commands ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || crate::mcp::register_mcp(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Registration task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
|
||||
tokio::task::spawn_blocking(crate::mcp::check_mcp_status)
|
||||
.await
|
||||
.map_err(|e| format!("MCP status check failed: {e}"))
|
||||
}
|
||||
|
||||
// ── Theme commands ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::list_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_theme(vault_path: String, theme_id: String) -> Result<ThemeFile, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::get_theme(&vault_path, &theme_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_vault_settings(vault_path: String) -> Result<VaultSettings, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::get_vault_settings(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_vault_settings(vault_path: String, settings: VaultSettings) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::save_vault_settings(&vault_path, settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_active_theme(vault_path: String, theme_id: Option<String>) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::set_active_theme(&vault_path, theme_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::create_theme(&vault_path, source_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_vault_theme(vault_path: String, name: Option<String>) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::create_vault_theme(&vault_path, name.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ensure_vault_themes(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::ensure_vault_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::restore_default_themes(&vault_path)
|
||||
}
|
||||
|
||||
// ── Settings & config commands ──────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_build_number(app_handle: tauri::AppHandle) -> String {
|
||||
let version = app_handle.package_info().version.to_string();
|
||||
parse_build_label(&version)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_menu_state(
|
||||
app_handle: tauri::AppHandle,
|
||||
has_active_note: bool,
|
||||
has_modified_files: Option<bool>,
|
||||
has_conflicts: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
menu::set_note_items_enabled(&app_handle, has_active_note);
|
||||
if let Some(v) = has_modified_files {
|
||||
menu::set_git_commit_items_enabled(&app_handle, v);
|
||||
}
|
||||
if let Some(v) = has_conflicts {
|
||||
menu::set_git_conflict_items_enabled(&app_handle, v);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_settings() -> Result<Settings, String> {
|
||||
crate::settings::get_settings()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
crate::settings::save_settings(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_vault_list() -> Result<VaultList, String> {
|
||||
vault_list::load_vault_list()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_vault_list(list: VaultList) -> Result<(), String> {
|
||||
vault_list::save_vault_list(&list)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_vault_config(vault_path: String) -> Result<VaultConfig, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault_config::get_vault_config(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_vault_config(vault_path: String, config: VaultConfig) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault_config::save_vault_config(&vault_path, config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_with_subpath() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
let result = expand_tilde("~/Documents/vault");
|
||||
assert_eq!(result, format!("{}/Documents/vault", home.display()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_alone() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
let result = expand_tilde("~");
|
||||
assert_eq!(result, home.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_noop_for_absolute_path() {
|
||||
let result = expand_tilde("/usr/local/bin");
|
||||
assert_eq!(result, "/usr/local/bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_noop_for_relative_path() {
|
||||
let result = expand_tilde("some/relative/path");
|
||||
assert_eq!(result, "some/relative/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_noop_for_tilde_in_middle() {
|
||||
let result = expand_tilde("/home/~user/path");
|
||||
assert_eq!(result, "/home/~user/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_release_version() {
|
||||
assert_eq!(parse_build_label("0.20260303.281"), "b281");
|
||||
assert_eq!(parse_build_label("0.20251215.42"), "b42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_dev_version() {
|
||||
assert_eq!(parse_build_label("0.1.0"), "dev");
|
||||
assert_eq!(parse_build_label("0.0.0"), "dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_malformed() {
|
||||
assert_eq!(parse_build_label("invalid"), "b?");
|
||||
assert_eq!(parse_build_label(""), "b?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_archive_notes() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let note = dir.path().join("note.md");
|
||||
std::fs::write(¬e, "---\nStatus: Active\n---\n# Note\n").unwrap();
|
||||
|
||||
let result = batch_archive_notes(vec![note.to_str().unwrap().to_string()]);
|
||||
assert_eq!(result.unwrap(), 1);
|
||||
|
||||
let content = std::fs::read_to_string(¬e).unwrap();
|
||||
assert!(content.contains("Archived: true"));
|
||||
assert!(content.contains("Status: Active"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_trash_notes() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let note = dir.path().join("note.md");
|
||||
std::fs::write(¬e, "---\nStatus: Active\n---\n# Note\n").unwrap();
|
||||
|
||||
let result = batch_trash_notes(vec![note.to_str().unwrap().to_string()]);
|
||||
assert_eq!(result.unwrap(), 1);
|
||||
|
||||
let content = std::fs::read_to_string(¬e).unwrap();
|
||||
assert!(content.contains("Trashed: true"));
|
||||
assert!(content.contains("Trashed at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_vault_exists_false() {
|
||||
assert!(!check_vault_exists("/nonexistent/path/abc123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_default_vault_path_returns_ok() {
|
||||
let result = get_default_vault_path();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,827 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Value type for frontmatter updates
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum FrontmatterValue {
|
||||
String(String),
|
||||
Number(f64),
|
||||
Bool(bool),
|
||||
List(Vec<String>),
|
||||
Null,
|
||||
}
|
||||
|
||||
/// Characters that require a YAML string value to be quoted.
|
||||
fn has_yaml_special_chars(s: &str) -> bool {
|
||||
s.contains(':') || s.contains('#')
|
||||
}
|
||||
|
||||
/// Check if a string starts with a YAML collection indicator (array or map).
|
||||
fn starts_as_yaml_collection(s: &str) -> bool {
|
||||
s.starts_with('[') || s.starts_with('{')
|
||||
}
|
||||
|
||||
/// Check whether a YAML string value needs quoting to avoid ambiguity.
|
||||
fn needs_yaml_quoting(s: &str) -> bool {
|
||||
has_yaml_special_chars(s)
|
||||
|| starts_as_yaml_collection(s)
|
||||
|| matches!(s, "true" | "false" | "null")
|
||||
|| s.parse::<f64>().is_ok()
|
||||
}
|
||||
|
||||
/// Quote a string value for YAML, escaping internal double quotes.
|
||||
fn quote_yaml_string(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('\"', "\\\""))
|
||||
}
|
||||
|
||||
/// Format a single YAML list item as ` - "value"`.
|
||||
fn format_list_item(item: &str) -> String {
|
||||
format!(" - {}", quote_yaml_string(item))
|
||||
}
|
||||
|
||||
/// Format a multi-line string as a YAML block scalar (`|`).
|
||||
/// Each line is indented by 2 spaces; empty lines are preserved as blank.
|
||||
fn format_block_scalar(s: &str) -> String {
|
||||
let indented = s
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", l)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("|\n{}", indented)
|
||||
}
|
||||
|
||||
/// Format a number for YAML (integers without decimal, floats with).
|
||||
fn format_yaml_number(n: f64) -> String {
|
||||
if n.fract() == 0.0 {
|
||||
format!("{}", n as i64)
|
||||
} else {
|
||||
format!("{}", n)
|
||||
}
|
||||
}
|
||||
|
||||
impl FrontmatterValue {
|
||||
pub fn to_yaml_value(&self) -> String {
|
||||
match self {
|
||||
FrontmatterValue::String(s) => {
|
||||
if s.contains('\n') {
|
||||
format_block_scalar(s)
|
||||
} else 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::Null => "null".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 != '-')
|
||||
}
|
||||
|
||||
/// Format a key for YAML output (quote if necessary)
|
||||
pub fn format_yaml_key(key: &str) -> String {
|
||||
if needs_key_quoting(key) {
|
||||
format!("\"{}\"", key)
|
||||
} else {
|
||||
key.to_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
|
||||
}
|
||||
|
||||
/// Format a key-value pair as one or more YAML lines.
|
||||
fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec<String> {
|
||||
let yaml_key = format_yaml_key(key);
|
||||
let yaml_value = value.to_yaml_value();
|
||||
if yaml_value.starts_with("|\n") {
|
||||
// Block scalar: key and indicator on the same line, content follows
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
} else if yaml_value.contains('\n') {
|
||||
vec![format!("{}:", yaml_key), yaml_value]
|
||||
} else {
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a line continues the previous key's value (indented list item,
|
||||
/// block scalar content, or blank line inside a block scalar).
|
||||
fn is_value_continuation(line: &str) -> bool {
|
||||
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
|
||||
}
|
||||
|
||||
/// Split content into frontmatter body and the rest after the closing `---`.
|
||||
/// Returns `(fm_content, rest)` where `fm_content` is between the opening and closing `---`.
|
||||
fn split_frontmatter(content: &str) -> Result<(&str, &str), String> {
|
||||
let after_open = &content[4..];
|
||||
// Handle empty frontmatter: closing --- immediately after opening ---\n
|
||||
if let Some(stripped) = after_open.strip_prefix("---") {
|
||||
return Ok(("", stripped));
|
||||
}
|
||||
let fm_end = after_open
|
||||
.find("\n---")
|
||||
.map(|i| i + 4)
|
||||
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
|
||||
Ok((&content[4..fm_end], &content[fm_end + 4..]))
|
||||
}
|
||||
|
||||
/// Wrap content in a new frontmatter block containing a single field.
|
||||
fn prepend_new_frontmatter(content: &str, key: &str, value: &FrontmatterValue) -> String {
|
||||
let field_lines = format_yaml_field(key, value);
|
||||
format!("---\n{}\n---\n{}", field_lines.join("\n"), content)
|
||||
}
|
||||
|
||||
/// Apply a field update to existing frontmatter lines.
|
||||
/// Replaces the matching key (and its list continuations) with the new value,
|
||||
/// or appends if the key is not found. If `value` is None, removes the key.
|
||||
fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue>) -> Vec<String> {
|
||||
let mut new_lines: Vec<String> = Vec::new();
|
||||
let mut found_key = false;
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
if !line_is_key(lines[i], key) {
|
||||
new_lines.push(lines[i].to_string());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
found_key = true;
|
||||
i += 1;
|
||||
// Skip continuation lines belonging to this key (lists, block scalars)
|
||||
while i < lines.len() && is_value_continuation(lines[i]) {
|
||||
i += 1;
|
||||
}
|
||||
// Insert replacement value (if any)
|
||||
if let Some(v) = value {
|
||||
new_lines.extend(format_yaml_field(key, v));
|
||||
}
|
||||
}
|
||||
|
||||
if let (false, Some(v)) = (found_key, value) {
|
||||
new_lines.extend(format_yaml_field(key, v));
|
||||
}
|
||||
|
||||
new_lines
|
||||
}
|
||||
|
||||
/// Internal function to update frontmatter content
|
||||
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)),
|
||||
None => Ok(content.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let (fm_content, rest) = split_frontmatter(content)?;
|
||||
let lines: Vec<&str> = fm_content.lines().collect();
|
||||
let new_lines = apply_field_update(&lines, key, value.as_ref());
|
||||
let new_fm = new_lines.join("\n");
|
||||
Ok(format!("---\n{}\n---{}", new_fm, rest))
|
||||
}
|
||||
|
||||
/// Helper to read a file, apply a frontmatter transformation, and write back.
|
||||
pub fn with_frontmatter<F>(path: &str, transform: F) -> Result<String, String>
|
||||
where
|
||||
F: FnOnce(&str) -> Result<String, String>,
|
||||
{
|
||||
let file_path = Path::new(path);
|
||||
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 updated = transform(&content)?;
|
||||
|
||||
fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// 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()))
|
||||
})
|
||||
}
|
||||
|
||||
/// 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)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Status: Active"));
|
||||
assert!(!updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_add_new_key() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Owner: Luca"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_quoted_key() {
|
||||
let content = "---\n\"Is A\": Note\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Is A",
|
||||
Some(FrontmatterValue::String("Project".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("\"Is A\": Project"));
|
||||
assert!(!updated.contains("Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec![
|
||||
"Alias1".to_string(),
|
||||
"Alias2".to_string(),
|
||||
])),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("aliases:"));
|
||||
assert!(updated.contains(" - \"Alias1\""));
|
||||
assert!(updated.contains(" - \"Alias2\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_replace_list() {
|
||||
let content = "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec!["New1".to_string()])),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" - \"New1\""));
|
||||
assert!(!updated.contains("Old1"));
|
||||
assert!(!updated.contains("Old2"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_property() {
|
||||
let content = "---\nStatus: Draft\nOwner: Luca\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "Owner", None).unwrap();
|
||||
assert!(!updated.contains("Owner"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_list_property() {
|
||||
let content = "---\naliases:\n - Alias1\n - Alias2\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "aliases", None).unwrap();
|
||||
assert!(!updated.contains("aliases"));
|
||||
assert!(!updated.contains("Alias1"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_existing() {
|
||||
let content = "# Test\n\nSome content here.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Draft".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.starts_with("---\n"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
assert!(updated.contains("# Test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_bool() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Reviewed", Some(FrontmatterValue::Bool(true)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Reviewed: true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_simple() {
|
||||
assert_eq!(format_yaml_key("Status"), "Status");
|
||||
assert_eq!(format_yaml_key("is_a"), "is_a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_spaces() {
|
||||
assert_eq!(format_yaml_key("Is A"), "\"Is A\"");
|
||||
assert_eq!(format_yaml_key("Created at"), "\"Created at\"");
|
||||
}
|
||||
|
||||
// --- to_yaml_value quoting tests ---
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_colon() {
|
||||
let v = FrontmatterValue::String("key: value".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"key: value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_hash() {
|
||||
let v = FrontmatterValue::String("has # comment".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"has # comment\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_bracket() {
|
||||
let v = FrontmatterValue::String("[array-like]".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"[array-like]\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_brace() {
|
||||
let v = FrontmatterValue::String("{object-like}".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"{object-like}\"");
|
||||
}
|
||||
|
||||
#[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\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_null_like() {
|
||||
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\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_plain() {
|
||||
let v = FrontmatterValue::String("Hello World".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_number_integer() {
|
||||
let v = FrontmatterValue::Number(42.0);
|
||||
assert_eq!(v.to_yaml_value(), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_number_float() {
|
||||
let v = FrontmatterValue::Number(3.14);
|
||||
assert_eq!(v.to_yaml_value(), "3.14");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_null() {
|
||||
assert_eq!(FrontmatterValue::Null.to_yaml_value(), "null");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_empty_list() {
|
||||
let v = FrontmatterValue::List(vec![]);
|
||||
assert_eq!(v.to_yaml_value(), "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_list_with_colon() {
|
||||
let v = FrontmatterValue::List(vec!["key: value".to_string()]);
|
||||
assert_eq!(v.to_yaml_value(), " - \"key: value\"");
|
||||
}
|
||||
|
||||
// --- update_frontmatter_content additional type 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();
|
||||
assert!(updated.contains("Priority: 5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_number_float() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Score", Some(FrontmatterValue::Number(9.5)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Score: 9.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_null() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "ClearMe", Some(FrontmatterValue::Null)).unwrap();
|
||||
assert!(updated.contains("ClearMe: null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_empty_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "tags", Some(FrontmatterValue::List(vec![])))
|
||||
.unwrap();
|
||||
assert!(updated.contains("tags: []"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_malformed_no_closing_fence() {
|
||||
let content = "---\nStatus: Draft\nNo closing fence here";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Malformed frontmatter"));
|
||||
}
|
||||
|
||||
// --- delete non-existent key (should be no-op) ---
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_key_noop() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_from_no_frontmatter_noop() {
|
||||
let content = "# Test\n\nSome content.";
|
||||
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
// --- line_is_key tests ---
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_unquoted() {
|
||||
assert!(line_is_key("Status: Draft", "Status"));
|
||||
assert!(!line_is_key("Status: Draft", "Owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_double_quoted() {
|
||||
assert!(line_is_key("\"Is A\": Note", "Is A"));
|
||||
assert!(!line_is_key("\"Is A\": Note", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_single_quoted() {
|
||||
assert!(line_is_key("'Is A': Note", "Is A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_leading_whitespace() {
|
||||
assert!(line_is_key(" Status: Draft", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_partial_match() {
|
||||
// "StatusBar" should not match key "Status"
|
||||
assert!(!line_is_key("StatusBar: value", "Status"));
|
||||
}
|
||||
|
||||
// --- with_frontmatter error cases ---
|
||||
|
||||
#[test]
|
||||
fn test_with_frontmatter_file_not_found() {
|
||||
let result = with_frontmatter("/nonexistent/path/file.md", |c| Ok(c.to_string()));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
// --- roundtrip 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();
|
||||
// Parse back with gray_matter
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
assert_eq!(map.get("Status").unwrap().as_string().unwrap(), "Active");
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[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 matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
let aliases = map.get("aliases").unwrap();
|
||||
if let gray_matter::Pod::Array(arr) = aliases {
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0].as_string().unwrap(), "A");
|
||||
assert_eq!(arr[1].as_string().unwrap(), "B");
|
||||
} else {
|
||||
panic!("Expected array");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
assert!(with_owner.contains("Owner: Luca"));
|
||||
let without_owner = update_frontmatter_content(&with_owner, "Owner", None).unwrap();
|
||||
assert!(!without_owner.contains("Owner"));
|
||||
assert!(without_owner.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
// --- format_yaml_key additional tests ---
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_colon() {
|
||||
assert_eq!(format_yaml_key("key:value"), "\"key:value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_hash() {
|
||||
assert_eq!(format_yaml_key("has#tag"), "\"has#tag\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_period() {
|
||||
assert_eq!(format_yaml_key("key.name"), "\"key.name\"");
|
||||
}
|
||||
|
||||
// --- split_frontmatter / empty frontmatter edge cases ---
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block() {
|
||||
// ---\n---\n (no fields between opening and closing ---)
|
||||
let result = split_frontmatter("---\n---\n");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"split_frontmatter should handle empty frontmatter block"
|
||||
);
|
||||
let (fm, rest) = result.unwrap();
|
||||
assert_eq!(fm, "");
|
||||
assert_eq!(rest, "\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block_no_trailing_newline() {
|
||||
// ---\n--- (no trailing newline)
|
||||
let result = split_frontmatter("---\n---");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"split_frontmatter should handle empty frontmatter without trailing newline"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block_with_body() {
|
||||
// ---\n---\n\n# Title\n
|
||||
let result = split_frontmatter("---\n---\n\n# Title\n");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"split_frontmatter should handle empty frontmatter with body"
|
||||
);
|
||||
let (fm, rest) = result.unwrap();
|
||||
assert_eq!(fm, "");
|
||||
assert!(rest.contains("# Title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_empty_block() {
|
||||
let content = "---\n---\n\n# Test\n";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"title",
|
||||
Some(FrontmatterValue::String("New Title".to_string())),
|
||||
);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"update_frontmatter_content should handle empty frontmatter block"
|
||||
);
|
||||
let updated = result.unwrap();
|
||||
assert!(updated.contains("title: New Title"));
|
||||
}
|
||||
|
||||
// --- block scalar (multi-line string) tests ---
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_multiline_uses_block_scalar() {
|
||||
let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string());
|
||||
let yaml = v.to_yaml_value();
|
||||
assert!(yaml.starts_with("|\n"));
|
||||
assert!(yaml.contains(" line 1"));
|
||||
assert!(yaml.contains(" line 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_field_block_scalar() {
|
||||
let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string());
|
||||
let lines = format_yaml_field("template", &v);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].starts_with("template: |\n"));
|
||||
assert!(lines[0].contains(" ## Objective"));
|
||||
assert!(lines[0].contains(" ## Timeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_add() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\n## Timeline";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("template: |"));
|
||||
assert!(updated.contains(" ## Objective"));
|
||||
assert!(updated.contains(" ## Timeline"));
|
||||
assert!(updated.contains("type: Type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_replace() {
|
||||
let content = "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n";
|
||||
let new_template = "## New\n\n## Content";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(new_template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" ## New"));
|
||||
assert!(updated.contains(" ## Content"));
|
||||
assert!(!updated.contains("## Old"));
|
||||
assert!(!updated.contains("## Stuff"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_block_scalar() {
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
|
||||
let updated = update_frontmatter_content(content, "template", None).unwrap();
|
||||
assert!(!updated.contains("template"));
|
||||
assert!(!updated.contains("## Heading"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_block_scalar() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
// Parse back with gray_matter
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
let roundtripped = map.get("template").unwrap().as_string().unwrap();
|
||||
assert!(roundtripped.contains("## Objective"));
|
||||
assert!(roundtripped.contains("## Timeline"));
|
||||
assert!(roundtripped.contains("Describe the goal."));
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_body_after_closing() {
|
||||
// Frontmatter with title, no body after closing ---
|
||||
let content = "---\ntitle: Old\n---\n";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"title",
|
||||
Some(FrontmatterValue::String("New".to_string())),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
let updated = result.unwrap();
|
||||
assert!(updated.contains("title: New"));
|
||||
assert!(!updated.contains("title: Old"));
|
||||
}
|
||||
}
|
||||
207
src-tauri/src/frontmatter/mod.rs
Normal file
207
src-tauri/src/frontmatter/mod.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
mod ops;
|
||||
mod yaml;
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub use ops::update_frontmatter_content;
|
||||
pub use yaml::{format_yaml_key, FrontmatterValue};
|
||||
|
||||
/// Helper to read a file, apply a frontmatter transformation, and write back.
|
||||
pub fn with_frontmatter<F>(path: &str, transform: F) -> Result<String, String>
|
||||
where
|
||||
F: FnOnce(&str) -> Result<String, String>,
|
||||
{
|
||||
let file_path = Path::new(path);
|
||||
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 updated = transform(&content)?;
|
||||
|
||||
fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// 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()))
|
||||
})
|
||||
}
|
||||
|
||||
/// 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)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_with_frontmatter_file_not_found() {
|
||||
let result = with_frontmatter("/nonexistent/path/file.md", |c| Ok(c.to_string()));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[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 matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
assert_eq!(map.get("Status").unwrap().as_string().unwrap(), "Active");
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[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 matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
let aliases = map.get("aliases").unwrap();
|
||||
if let gray_matter::Pod::Array(arr) = aliases {
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0].as_string().unwrap(), "A");
|
||||
assert_eq!(arr[1].as_string().unwrap(), "B");
|
||||
} else {
|
||||
panic!("Expected array");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
assert!(with_owner.contains("Owner: Luca"));
|
||||
let without_owner = update_frontmatter_content(&with_owner, "Owner", None).unwrap();
|
||||
assert!(!without_owner.contains("Owner"));
|
||||
assert!(without_owner.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_empty_block() {
|
||||
let content = "---\n---\n\n# Test\n";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"title",
|
||||
Some(FrontmatterValue::String("New Title".to_string())),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().contains("title: New Title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_add() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\n## Timeline";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("template: |"));
|
||||
assert!(updated.contains(" ## Objective"));
|
||||
assert!(updated.contains("type: Type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_replace() {
|
||||
let content = "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String("## New\n\n## Content".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" ## New"));
|
||||
assert!(!updated.contains("## Old"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_block_scalar() {
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
|
||||
let updated = update_frontmatter_content(content, "template", None).unwrap();
|
||||
assert!(!updated.contains("template"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_body_after_closing() {
|
||||
let content = "---\ntitle: Old\n---\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"title",
|
||||
Some(FrontmatterValue::String("New".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("title: New"));
|
||||
assert!(!updated.contains("title: Old"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_block_scalar() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
let roundtripped = map.get("template").unwrap().as_string().unwrap();
|
||||
assert!(roundtripped.contains("## Objective"));
|
||||
assert!(roundtripped.contains("## Timeline"));
|
||||
assert!(roundtripped.contains("Describe the goal."));
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
}
|
||||
331
src-tauri/src/frontmatter/ops.rs
Normal file
331
src-tauri/src/frontmatter/ops.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
use super::yaml::{format_yaml_field, FrontmatterValue};
|
||||
|
||||
/// Check if a line defines a specific key (handles quoted and unquoted keys)
|
||||
fn line_is_key(line: &str, key: &str) -> bool {
|
||||
let trimmed = line.trim_start();
|
||||
|
||||
if trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':') {
|
||||
return true;
|
||||
}
|
||||
|
||||
let dq = format!("\"{}\":", key);
|
||||
if trimmed.starts_with(&dq) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let sq = format!("'{}\':", key);
|
||||
if trimmed.starts_with(&sq) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a line continues the previous key's value (indented list item,
|
||||
/// block scalar content, or blank line inside a block scalar).
|
||||
fn is_value_continuation(line: &str) -> bool {
|
||||
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
|
||||
}
|
||||
|
||||
/// Split content into frontmatter body and the rest after the closing `---`.
|
||||
/// Returns `(fm_content, rest)` where `fm_content` is between the opening and closing `---`.
|
||||
fn split_frontmatter(content: &str) -> Result<(&str, &str), String> {
|
||||
let after_open = &content[4..];
|
||||
// Handle empty frontmatter: closing --- immediately after opening ---\n
|
||||
if let Some(stripped) = after_open.strip_prefix("---") {
|
||||
return Ok(("", stripped));
|
||||
}
|
||||
let fm_end = after_open
|
||||
.find("\n---")
|
||||
.map(|i| i + 4)
|
||||
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
|
||||
Ok((&content[4..fm_end], &content[fm_end + 4..]))
|
||||
}
|
||||
|
||||
/// Wrap content in a new frontmatter block containing a single field.
|
||||
fn prepend_new_frontmatter(content: &str, key: &str, value: &FrontmatterValue) -> String {
|
||||
let field_lines = format_yaml_field(key, value);
|
||||
format!("---\n{}\n---\n{}", field_lines.join("\n"), content)
|
||||
}
|
||||
|
||||
/// Apply a field update to existing frontmatter lines.
|
||||
/// Replaces the matching key (and its list continuations) with the new value,
|
||||
/// or appends if the key is not found. If `value` is None, removes the key.
|
||||
fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue>) -> Vec<String> {
|
||||
let mut new_lines: Vec<String> = Vec::new();
|
||||
let mut found_key = false;
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
if !line_is_key(lines[i], key) {
|
||||
new_lines.push(lines[i].to_string());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
found_key = true;
|
||||
i += 1;
|
||||
// Skip continuation lines belonging to this key (lists, block scalars)
|
||||
while i < lines.len() && is_value_continuation(lines[i]) {
|
||||
i += 1;
|
||||
}
|
||||
// Insert replacement value (if any)
|
||||
if let Some(v) = value {
|
||||
new_lines.extend(format_yaml_field(key, v));
|
||||
}
|
||||
}
|
||||
|
||||
if let (false, Some(v)) = (found_key, value) {
|
||||
new_lines.extend(format_yaml_field(key, v));
|
||||
}
|
||||
|
||||
new_lines
|
||||
}
|
||||
|
||||
/// Internal function to update frontmatter content
|
||||
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)),
|
||||
None => Ok(content.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let (fm_content, rest) = split_frontmatter(content)?;
|
||||
let lines: Vec<&str> = fm_content.lines().collect();
|
||||
let new_lines = apply_field_update(&lines, key, value.as_ref());
|
||||
let new_fm = new_lines.join("\n");
|
||||
Ok(format!("---\n{}\n---{}", new_fm, rest))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Status: Active"));
|
||||
assert!(!updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_add_new_key() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Owner: Luca"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_quoted_key() {
|
||||
let content = "---\n\"Is A\": Note\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Is A",
|
||||
Some(FrontmatterValue::String("Project".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("\"Is A\": Project"));
|
||||
assert!(!updated.contains("Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec![
|
||||
"Alias1".to_string(),
|
||||
"Alias2".to_string(),
|
||||
])),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("aliases:"));
|
||||
assert!(updated.contains(" - \"Alias1\""));
|
||||
assert!(updated.contains(" - \"Alias2\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_replace_list() {
|
||||
let content = "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec!["New1".to_string()])),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" - \"New1\""));
|
||||
assert!(!updated.contains("Old1"));
|
||||
assert!(!updated.contains("Old2"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_property() {
|
||||
let content = "---\nStatus: Draft\nOwner: Luca\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "Owner", None).unwrap();
|
||||
assert!(!updated.contains("Owner"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_list_property() {
|
||||
let content = "---\naliases:\n - Alias1\n - Alias2\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "aliases", None).unwrap();
|
||||
assert!(!updated.contains("aliases"));
|
||||
assert!(!updated.contains("Alias1"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_existing() {
|
||||
let content = "# Test\n\nSome content here.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Draft".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.starts_with("---\n"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
assert!(updated.contains("# Test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_bool() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Reviewed", Some(FrontmatterValue::Bool(true)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Reviewed: true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_number() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Priority", Some(FrontmatterValue::Number(5.0)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Priority: 5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_number_float() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Score", Some(FrontmatterValue::Number(9.5)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Score: 9.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_null() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "ClearMe", Some(FrontmatterValue::Null)).unwrap();
|
||||
assert!(updated.contains("ClearMe: null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_empty_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "tags", Some(FrontmatterValue::List(vec![])))
|
||||
.unwrap();
|
||||
assert!(updated.contains("tags: []"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_malformed_no_closing_fence() {
|
||||
let content = "---\nStatus: Draft\nNo closing fence here";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Malformed frontmatter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_key_noop() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_from_no_frontmatter_noop() {
|
||||
let content = "# Test\n\nSome content.";
|
||||
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_unquoted() {
|
||||
assert!(line_is_key("Status: Draft", "Status"));
|
||||
assert!(!line_is_key("Status: Draft", "Owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_double_quoted() {
|
||||
assert!(line_is_key("\"Is A\": Note", "Is A"));
|
||||
assert!(!line_is_key("\"Is A\": Note", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_single_quoted() {
|
||||
assert!(line_is_key("'Is A': Note", "Is A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_leading_whitespace() {
|
||||
assert!(line_is_key(" Status: Draft", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_partial_match() {
|
||||
assert!(!line_is_key("StatusBar: value", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block() {
|
||||
let result = split_frontmatter("---\n---\n");
|
||||
assert!(result.is_ok());
|
||||
let (fm, rest) = result.unwrap();
|
||||
assert_eq!(fm, "");
|
||||
assert_eq!(rest, "\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block_no_trailing_newline() {
|
||||
let result = split_frontmatter("---\n---");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block_with_body() {
|
||||
let result = split_frontmatter("---\n---\n\n# Title\n");
|
||||
assert!(result.is_ok());
|
||||
let (fm, rest) = result.unwrap();
|
||||
assert_eq!(fm, "");
|
||||
assert!(rest.contains("# Title"));
|
||||
}
|
||||
}
|
||||
262
src-tauri/src/frontmatter/yaml.rs
Normal file
262
src-tauri/src/frontmatter/yaml.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Value type for frontmatter updates
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum FrontmatterValue {
|
||||
String(String),
|
||||
Number(f64),
|
||||
Bool(bool),
|
||||
List(Vec<String>),
|
||||
Null,
|
||||
}
|
||||
|
||||
/// Characters that require a YAML string value to be quoted.
|
||||
fn has_yaml_special_chars(s: &str) -> bool {
|
||||
s.contains(':') || s.contains('#')
|
||||
}
|
||||
|
||||
/// Check if a string starts with a YAML collection indicator (array or map).
|
||||
fn starts_as_yaml_collection(s: &str) -> bool {
|
||||
s.starts_with('[') || s.starts_with('{')
|
||||
}
|
||||
|
||||
/// Check whether a YAML string value needs quoting to avoid ambiguity.
|
||||
fn needs_yaml_quoting(s: &str) -> bool {
|
||||
has_yaml_special_chars(s)
|
||||
|| starts_as_yaml_collection(s)
|
||||
|| matches!(s, "true" | "false" | "null")
|
||||
|| s.parse::<f64>().is_ok()
|
||||
}
|
||||
|
||||
/// Quote a string value for YAML, escaping internal double quotes.
|
||||
fn quote_yaml_string(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('\"', "\\\""))
|
||||
}
|
||||
|
||||
/// Format a single YAML list item as ` - "value"`.
|
||||
fn format_list_item(item: &str) -> String {
|
||||
format!(" - {}", quote_yaml_string(item))
|
||||
}
|
||||
|
||||
/// Format a multi-line string as a YAML block scalar (`|`).
|
||||
/// Each line is indented by 2 spaces; empty lines are preserved as blank.
|
||||
fn format_block_scalar(s: &str) -> String {
|
||||
let indented = s
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", l)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("|\n{}", indented)
|
||||
}
|
||||
|
||||
/// Format a number for YAML (integers without decimal, floats with).
|
||||
fn format_yaml_number(n: f64) -> String {
|
||||
if n.fract() == 0.0 {
|
||||
format!("{}", n as i64)
|
||||
} else {
|
||||
format!("{}", n)
|
||||
}
|
||||
}
|
||||
|
||||
impl FrontmatterValue {
|
||||
pub fn to_yaml_value(&self) -> String {
|
||||
match self {
|
||||
FrontmatterValue::String(s) => {
|
||||
if s.contains('\n') {
|
||||
format_block_scalar(s)
|
||||
} else 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::Null => "null".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 != '-')
|
||||
}
|
||||
|
||||
/// Format a key for YAML output (quote if necessary)
|
||||
pub fn format_yaml_key(key: &str) -> String {
|
||||
if needs_key_quoting(key) {
|
||||
format!("\"{}\"", key)
|
||||
} else {
|
||||
key.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a key-value pair as one or more YAML lines.
|
||||
pub fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec<String> {
|
||||
let yaml_key = format_yaml_key(key);
|
||||
let yaml_value = value.to_yaml_value();
|
||||
if yaml_value.starts_with("|\n") {
|
||||
// Block scalar: key and indicator on the same line, content follows
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
} else if yaml_value.contains('\n') {
|
||||
vec![format!("{}:", yaml_key), yaml_value]
|
||||
} else {
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_colon() {
|
||||
let v = FrontmatterValue::String("key: value".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"key: value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_hash() {
|
||||
let v = FrontmatterValue::String("has # comment".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"has # comment\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_bracket() {
|
||||
let v = FrontmatterValue::String("[array-like]".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"[array-like]\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_brace() {
|
||||
let v = FrontmatterValue::String("{object-like}".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"{object-like}\"");
|
||||
}
|
||||
|
||||
#[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\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_null_like() {
|
||||
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\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_plain() {
|
||||
let v = FrontmatterValue::String("Hello World".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_number_integer() {
|
||||
let v = FrontmatterValue::Number(42.0);
|
||||
assert_eq!(v.to_yaml_value(), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_number_float() {
|
||||
let v = FrontmatterValue::Number(3.14);
|
||||
assert_eq!(v.to_yaml_value(), "3.14");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_null() {
|
||||
assert_eq!(FrontmatterValue::Null.to_yaml_value(), "null");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_empty_list() {
|
||||
let v = FrontmatterValue::List(vec![]);
|
||||
assert_eq!(v.to_yaml_value(), "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_list_with_colon() {
|
||||
let v = FrontmatterValue::List(vec!["key: value".to_string()]);
|
||||
assert_eq!(v.to_yaml_value(), " - \"key: value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_multiline_uses_block_scalar() {
|
||||
let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string());
|
||||
let yaml = v.to_yaml_value();
|
||||
assert!(yaml.starts_with("|\n"));
|
||||
assert!(yaml.contains(" line 1"));
|
||||
assert!(yaml.contains(" line 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_simple() {
|
||||
assert_eq!(format_yaml_key("Status"), "Status");
|
||||
assert_eq!(format_yaml_key("is_a"), "is_a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_spaces() {
|
||||
assert_eq!(format_yaml_key("Is A"), "\"Is A\"");
|
||||
assert_eq!(format_yaml_key("Created at"), "\"Created at\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_colon() {
|
||||
assert_eq!(format_yaml_key("key:value"), "\"key:value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_hash() {
|
||||
assert_eq!(format_yaml_key("has#tag"), "\"has#tag\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_period() {
|
||||
assert_eq!(format_yaml_key("key.name"), "\"key.name\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_field_block_scalar() {
|
||||
let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string());
|
||||
let lines = format_yaml_field("template", &v);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].starts_with("template: |\n"));
|
||||
assert!(lines[0].contains(" ## Objective"));
|
||||
assert!(lines[0].contains(" ## Timeline"));
|
||||
}
|
||||
}
|
||||
1907
src-tauri/src/git.rs
1907
src-tauri/src/git.rs
File diff suppressed because it is too large
Load Diff
87
src-tauri/src/git/commit.rs
Normal file
87
src-tauri/src/git/commit.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Commit all changes with a message.
|
||||
pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
// Stage all changes
|
||||
let add = Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git add: {}", e))?;
|
||||
|
||||
if !add.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&add.stderr);
|
||||
return Err(format!("git add failed: {}", stderr));
|
||||
}
|
||||
|
||||
// Commit
|
||||
let commit = Command::new("git")
|
||||
.args(["commit", "-m", message])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git commit: {}", e))?;
|
||||
|
||||
if !commit.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&commit.stderr);
|
||||
let stdout = String::from_utf8_lossy(&commit.stdout);
|
||||
// git writes "nothing to commit" to stdout, not stderr
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
return Err(format!("git commit failed: {}", detail.trim()));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&commit.stdout).to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_git_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
fs::write(vault.join("commit-test.md"), "# Test\n").unwrap();
|
||||
|
||||
let result = git_commit(vault.to_str().unwrap(), "Test commit");
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify the commit exists
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Test commit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_nothing_to_commit_returns_error() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
// Create and commit, so working tree is clean
|
||||
fs::write(vault.join("clean.md"), "# Clean\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
// Committing again with no changes should fail
|
||||
let result = git_commit(vp, "nothing here");
|
||||
assert!(result.is_err(), "Commit should fail when nothing to commit");
|
||||
assert!(
|
||||
result.unwrap_err().contains("nothing to commit"),
|
||||
"Error should mention 'nothing to commit'"
|
||||
);
|
||||
}
|
||||
}
|
||||
366
src-tauri/src/git/conflict.rs
Normal file
366
src-tauri/src/git/conflict.rs
Normal file
@@ -0,0 +1,366 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::run_git;
|
||||
|
||||
/// List files with merge conflicts (unmerged paths).
|
||||
///
|
||||
/// Uses `git ls-files --unmerged` instead of `git diff --diff-filter=U` because
|
||||
/// ls-files reliably detects unmerged index entries even when the merge state is
|
||||
/// stale (e.g. after a reboot or when MERGE_HEAD is missing).
|
||||
pub fn get_conflict_files(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
.args(["ls-files", "--unmerged"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check conflicts: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
// Each unmerged file appears multiple times (once per stage: base/ours/theirs).
|
||||
// Format: "<mode> <hash> <stage>\t<path>"
|
||||
let mut files: Vec<String> = stdout
|
||||
.lines()
|
||||
.filter_map(|line| line.split('\t').nth(1).map(|s| s.to_string()))
|
||||
.collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Resolve a single conflict file by choosing "ours" or "theirs" strategy,
|
||||
/// then stage the result.
|
||||
pub fn git_resolve_conflict(vault_path: &str, file: &str, strategy: &str) -> Result<(), String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let checkout_flag = match strategy {
|
||||
"ours" => "--ours",
|
||||
"theirs" => "--theirs",
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Invalid strategy '{}': must be 'ours' or 'theirs'",
|
||||
strategy
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
run_git(vault, &["checkout", checkout_flag, "--", file])?;
|
||||
run_git(vault, &["add", "--", file])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether a rebase is currently in progress.
|
||||
pub fn is_rebase_in_progress(vault_path: &str) -> bool {
|
||||
let vault = Path::new(vault_path);
|
||||
let git_dir = vault.join(".git");
|
||||
git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists()
|
||||
}
|
||||
|
||||
/// Check whether a merge is currently in progress.
|
||||
pub fn is_merge_in_progress(vault_path: &str) -> bool {
|
||||
Path::new(vault_path)
|
||||
.join(".git")
|
||||
.join("MERGE_HEAD")
|
||||
.exists()
|
||||
}
|
||||
|
||||
/// Returns the current conflict mode: "rebase", "merge", or "none".
|
||||
pub fn get_conflict_mode(vault_path: &str) -> String {
|
||||
if is_rebase_in_progress(vault_path) {
|
||||
"rebase".to_string()
|
||||
} else if is_merge_in_progress(vault_path) {
|
||||
"merge".to_string()
|
||||
} else {
|
||||
"none".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit after all conflicts have been resolved.
|
||||
/// Detects whether the repo is in a merge or rebase state and uses the
|
||||
/// appropriate command (`git commit` vs `git rebase --continue`).
|
||||
pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
// Verify no remaining conflicts
|
||||
let remaining = get_conflict_files(vault_path)?;
|
||||
if !remaining.is_empty() {
|
||||
return Err(format!(
|
||||
"Cannot commit: {} file(s) still have unresolved conflicts",
|
||||
remaining.len()
|
||||
));
|
||||
}
|
||||
|
||||
let mode = get_conflict_mode(vault_path);
|
||||
let output = match mode.as_str() {
|
||||
"rebase" => Command::new("git")
|
||||
.args(["rebase", "--continue"])
|
||||
.env("GIT_EDITOR", "true")
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git rebase --continue: {}", e))?,
|
||||
_ => Command::new("git")
|
||||
.args(["commit", "-m", "Resolve merge conflicts"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git commit: {}", e))?,
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
let cmd_name = if mode == "rebase" {
|
||||
"git rebase --continue"
|
||||
} else {
|
||||
"git commit"
|
||||
};
|
||||
return Err(format!("{} failed: {}", cmd_name, detail.trim()));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair};
|
||||
use crate::git::{git_commit, git_pull, git_push};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_get_conflict_files_empty_when_clean() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
let conflicts = get_conflict_files(vp).unwrap();
|
||||
assert!(conflicts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_invalid_strategy() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let result = git_resolve_conflict(vp_b, "conflict.md", "invalid");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid strategy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflict_mode_none_for_clean_repo() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp), "none");
|
||||
assert!(!is_rebase_in_progress(vp));
|
||||
assert!(!is_merge_in_progress(vp));
|
||||
}
|
||||
|
||||
/// Set up a pair of clones that have a merge conflict on the same file.
|
||||
/// Returns (bare, clone_a, clone_b) where clone_b has an unresolved conflict.
|
||||
fn setup_conflict_pair() -> (TempDir, TempDir, TempDir) {
|
||||
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
|
||||
|
||||
let vp_a = clone_a_dir.path().to_str().unwrap();
|
||||
let vp_b = clone_b_dir.path().to_str().unwrap();
|
||||
|
||||
// A creates the file and pushes
|
||||
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
|
||||
git_commit(vp_a, "create conflict.md").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
// B pulls to get the file
|
||||
git_pull(vp_b).unwrap();
|
||||
|
||||
// A modifies and pushes
|
||||
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
|
||||
git_commit(vp_a, "A's change").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
// B modifies the same file locally and commits
|
||||
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
|
||||
git_commit(vp_b, "B's change").unwrap();
|
||||
|
||||
// B pulls — this causes a merge conflict
|
||||
let result = git_pull(vp_b).unwrap();
|
||||
assert_eq!(result.status, "conflict");
|
||||
|
||||
(bare_dir, clone_a_dir, clone_b_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_ours() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let conflicts = get_conflict_files(vp_b).unwrap();
|
||||
assert!(conflicts.contains(&"conflict.md".to_string()));
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
||||
|
||||
let remaining = get_conflict_files(vp_b).unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
|
||||
assert_eq!(content, "# Version B\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_theirs() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
|
||||
|
||||
let remaining = get_conflict_files(vp_b).unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
|
||||
assert_eq!(content, "# Version A\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_conflict_resolution() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
||||
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(clone_b.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Resolve merge conflicts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_conflict_resolution_fails_with_unresolved() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.contains("still have unresolved conflicts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflict_mode_merge_during_merge_conflict() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "merge");
|
||||
assert!(is_merge_in_progress(vp_b));
|
||||
assert!(!is_rebase_in_progress(vp_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_conflict_resolution_merge_mode() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "merge");
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "none");
|
||||
}
|
||||
|
||||
/// Set up a rebase conflict: clone_b has diverged from origin and
|
||||
/// `git pull --rebase` causes a conflict.
|
||||
fn setup_rebase_conflict_pair() -> (TempDir, TempDir, TempDir) {
|
||||
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
|
||||
|
||||
let vp_a = clone_a_dir.path().to_str().unwrap();
|
||||
let vp_b = clone_b_dir.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
|
||||
git_commit(vp_a, "create conflict.md").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
git_pull(vp_b).unwrap();
|
||||
|
||||
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
|
||||
git_commit(vp_a, "A's change").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
|
||||
git_commit(vp_b, "B's change").unwrap();
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["pull", "--rebase"])
|
||||
.current_dir(clone_b_dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Expected rebase conflict, but pull succeeded"
|
||||
);
|
||||
|
||||
(bare_dir, clone_a_dir, clone_b_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflict_mode_rebase_during_rebase_conflict() {
|
||||
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "rebase");
|
||||
assert!(is_rebase_in_progress(vp_b));
|
||||
assert!(!is_merge_in_progress(vp_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_conflict_files_during_rebase() {
|
||||
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let conflicts = get_conflict_files(vp_b).unwrap();
|
||||
assert!(
|
||||
conflicts.contains(&"conflict.md".to_string()),
|
||||
"Should detect conflict.md during rebase, got: {:?}",
|
||||
conflicts
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_and_continue_rebase() {
|
||||
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "rebase");
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
|
||||
let remaining = get_conflict_files(vp_b).unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_ok(), "rebase --continue failed: {:?}", result);
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "none");
|
||||
}
|
||||
}
|
||||
349
src-tauri/src/git/history.rs
Normal file
349
src-tauri/src/git/history.rs
Normal file
@@ -0,0 +1,349 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::GitCommit;
|
||||
|
||||
/// Get git log history for a specific file in the vault.
|
||||
pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitCommit>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
|
||||
let relative = file
|
||||
.strip_prefix(vault)
|
||||
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
|
||||
|
||||
let relative_str = relative
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"log",
|
||||
"--format=%H|%h|%an|%aI|%s",
|
||||
"-n",
|
||||
"20",
|
||||
"--",
|
||||
relative_str,
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git log: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// No commits yet is not an error - just return empty history
|
||||
if stderr.contains("does not have any commits yet") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return Err(format!("git log failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let commits = stdout
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.filter_map(|line| {
|
||||
// Format: hash|short_hash|author|date|message
|
||||
// Use splitn(5) so message (last) can contain '|'
|
||||
let parts: Vec<&str> = line.splitn(5, '|').collect();
|
||||
if parts.len() != 5 {
|
||||
return None;
|
||||
}
|
||||
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0);
|
||||
|
||||
Some(GitCommit {
|
||||
hash: parts[0].to_string(),
|
||||
short_hash: parts[1].to_string(),
|
||||
author: parts[2].to_string(),
|
||||
date,
|
||||
message: parts[4].to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
/// Get git diff for a specific file.
|
||||
pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
|
||||
let relative = file
|
||||
.strip_prefix(vault)
|
||||
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
|
||||
|
||||
let relative_str = relative
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
|
||||
// First try tracked file diff
|
||||
let output = Command::new("git")
|
||||
.args(["diff", "--", relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git diff: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
|
||||
// If no diff (maybe staged or untracked), try diff --cached
|
||||
if stdout.is_empty() {
|
||||
let cached = Command::new("git")
|
||||
.args(["diff", "--cached", "--", relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git diff --cached: {}", e))?;
|
||||
|
||||
let cached_stdout = String::from_utf8_lossy(&cached.stdout).to_string();
|
||||
if !cached_stdout.is_empty() {
|
||||
return Ok(cached_stdout);
|
||||
}
|
||||
|
||||
// Try showing untracked file as all-new
|
||||
let status = Command::new("git")
|
||||
.args(["status", "--porcelain", "--", relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {}", e))?;
|
||||
|
||||
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 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}",
|
||||
relative_str,
|
||||
lines.len(),
|
||||
lines.join("\n")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stdout)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
|
||||
let relative = file
|
||||
.strip_prefix(vault)
|
||||
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
|
||||
|
||||
let relative_str = relative
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
|
||||
// Show diff between commit^ and commit for this file
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"diff",
|
||||
&format!("{}^", commit_hash),
|
||||
commit_hash,
|
||||
"--",
|
||||
relative_str,
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git diff: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
|
||||
// If diff is empty, it might be the initial commit (no parent).
|
||||
// Fall back to showing the full file content as added.
|
||||
if stdout.is_empty() {
|
||||
let show = Command::new("git")
|
||||
.args(["show", &format!("{}:{}", commit_hash, relative_str)])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git show: {}", e))?;
|
||||
|
||||
if show.status.success() {
|
||||
let content = String::from_utf8_lossy(&show.stdout);
|
||||
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}",
|
||||
relative_str,
|
||||
lines.len(),
|
||||
lines.join("\n")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stdout)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_get_file_history_with_commits() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("test.md");
|
||||
|
||||
fs::write(&file, "# Initial\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
fs::write(&file, "# Updated\n\nNew content.").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Update test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.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");
|
||||
assert_eq!(history[1].message, "Initial commit");
|
||||
assert_eq!(history[0].author, "Test User");
|
||||
assert!(!history[0].hash.is_empty());
|
||||
assert!(!history[0].short_hash.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_history_no_commits() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
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();
|
||||
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_diff() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("diff-test.md");
|
||||
|
||||
fs::write(&file, "# Test\n\nOriginal content.").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "diff-test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Add diff-test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
fs::write(&file, "# Test\n\nModified content.").unwrap();
|
||||
|
||||
let diff = get_file_diff(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(!diff.is_empty());
|
||||
assert!(diff.contains("-Original content."));
|
||||
assert!(diff.contains("+Modified content."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_diff_at_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("diff-at-commit.md");
|
||||
|
||||
fs::write(&file, "# First\n\nOriginal content.").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "diff-at-commit.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "First commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
fs::write(&file, "# First\n\nModified content.").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "diff-at-commit.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Second commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Get hash of second commit
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.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();
|
||||
|
||||
assert!(!diff.is_empty());
|
||||
assert!(diff.contains("-Original content."));
|
||||
assert!(diff.contains("+Modified content."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_diff_at_initial_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("initial.md");
|
||||
|
||||
fs::write(&file, "# Initial\n\nHello world.").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "initial.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.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();
|
||||
|
||||
assert!(!diff.is_empty());
|
||||
assert!(diff.contains("+# Initial"));
|
||||
assert!(diff.contains("+Hello world."));
|
||||
}
|
||||
}
|
||||
352
src-tauri/src/git/mod.rs
Normal file
352
src-tauri/src/git/mod.rs
Normal file
@@ -0,0 +1,352 @@
|
||||
mod commit;
|
||||
mod conflict;
|
||||
mod history;
|
||||
mod pulse;
|
||||
mod remote;
|
||||
mod status;
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub use commit::git_commit;
|
||||
pub use conflict::{
|
||||
get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict,
|
||||
is_merge_in_progress, is_rebase_in_progress,
|
||||
};
|
||||
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
|
||||
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
|
||||
pub use remote::{git_pull, git_push, has_remote, GitPullResult};
|
||||
pub use status::{get_modified_files, ModifiedFile};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct GitCommit {
|
||||
pub hash: String,
|
||||
#[serde(rename = "shortHash")]
|
||||
pub short_hash: String,
|
||||
pub message: String,
|
||||
pub author: String,
|
||||
pub date: i64,
|
||||
}
|
||||
|
||||
/// Initialize a new git repository, stage all files, and create an initial commit.
|
||||
pub fn init_repo(path: &str) -> Result<(), String> {
|
||||
let dir = Path::new(path);
|
||||
|
||||
run_git(dir, &["init"])?;
|
||||
ensure_author_config(dir)?;
|
||||
|
||||
// Write .gitignore before the first commit so machine-specific and
|
||||
// macOS metadata files are never tracked and don't cause conflicts.
|
||||
let gitignore_path = dir.join(".gitignore");
|
||||
if !gitignore_path.exists() {
|
||||
std::fs::write(
|
||||
&gitignore_path,
|
||||
"# Laputa app files (machine-specific, never commit)\n\
|
||||
.laputa-cache.json\n\
|
||||
.laputa/settings.json\n\
|
||||
\n\
|
||||
# macOS\n\
|
||||
.DS_Store\n\
|
||||
.AppleDouble\n\
|
||||
.LSOverride\n\
|
||||
\n\
|
||||
# Thumbnails\n\
|
||||
._*\n\
|
||||
\n\
|
||||
# Editors\n\
|
||||
.vscode/\n\
|
||||
.idea/\n\
|
||||
*.swp\n\
|
||||
*.swo\n",
|
||||
)
|
||||
.map_err(|e| format!("Failed to write .gitignore: {}", e))?;
|
||||
}
|
||||
|
||||
run_git(dir, &["add", "."])?;
|
||||
run_git(dir, &["commit", "-m", "Initial vault setup"])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a git command in the given directory, returning an error on failure.
|
||||
fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git {}: {}", args[0], e))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"git {} failed: {}",
|
||||
args[0],
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
))
|
||||
}
|
||||
|
||||
/// Set local user.name and user.email if not already configured.
|
||||
fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
for (key, fallback) in [("user.name", "Laputa"), ("user.email", "vault@laputa.app")] {
|
||||
let check = Command::new("git")
|
||||
.args(["config", key])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check git config: {}", e))?;
|
||||
|
||||
let value = String::from_utf8_lossy(&check.stdout);
|
||||
if !check.status.success() || value.trim().is_empty() {
|
||||
run_git(dir, &["config", key, fallback])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract "owner/repo" from a GitHub remote URL.
|
||||
/// Supports HTTPS (https://github.com/owner/repo.git) and
|
||||
/// SSH (git@github.com:owner/repo.git) formats.
|
||||
fn parse_github_repo_path(url: &str) -> Option<String> {
|
||||
let trimmed = url.trim();
|
||||
|
||||
// SSH format: git@github.com:owner/repo.git
|
||||
if let Some(rest) = trimmed.strip_prefix("git@github.com:") {
|
||||
let path = rest.strip_suffix(".git").unwrap_or(rest);
|
||||
if path.contains('/') {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPS format: https://github.com/owner/repo.git
|
||||
// Also handle token-embedded URLs: https://token@github.com/owner/repo.git
|
||||
if trimmed.contains("github.com/") {
|
||||
let after = trimmed.split("github.com/").nth(1)?;
|
||||
let path = after.strip_suffix(".git").unwrap_or(after);
|
||||
if path.contains('/') {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
pub(crate) fn setup_git_repo() -> TempDir {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test User"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
dir
|
||||
}
|
||||
|
||||
/// Set up a bare "remote" and a clone that acts as the working vault.
|
||||
pub(crate) fn setup_remote_pair() -> (TempDir, TempDir, TempDir) {
|
||||
let bare_dir = TempDir::new().unwrap();
|
||||
let bare = bare_dir.path();
|
||||
|
||||
Command::new("git")
|
||||
.args(["init", "--bare"])
|
||||
.current_dir(bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let clone_a_dir = TempDir::new().unwrap();
|
||||
Command::new("git")
|
||||
.args(["clone", bare.to_str().unwrap(), "."])
|
||||
.current_dir(clone_a_dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
for cmd in &[
|
||||
&["config", "user.email", "a@test.com"][..],
|
||||
&["config", "user.name", "User A"][..],
|
||||
] {
|
||||
Command::new("git")
|
||||
.args(*cmd)
|
||||
.current_dir(clone_a_dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let clone_b_dir = TempDir::new().unwrap();
|
||||
Command::new("git")
|
||||
.args(["clone", bare.to_str().unwrap(), "."])
|
||||
.current_dir(clone_b_dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
for cmd in &[
|
||||
&["config", "user.email", "b@test.com"][..],
|
||||
&["config", "user.name", "User B"][..],
|
||||
] {
|
||||
Command::new("git")
|
||||
.args(*cmd)
|
||||
.current_dir(clone_b_dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
(bare_dir, clone_a_dir, clone_b_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_creates_git_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("new-vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Test\n").unwrap();
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(vault.join(".git").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_creates_initial_commit() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("new-vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Test\n").unwrap();
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Initial vault setup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_stages_all_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("new-vault");
|
||||
fs::create_dir_all(vault.join("sub")).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Test\n").unwrap();
|
||||
fs::write(vault.join("sub/nested.md"), "# Nested\n").unwrap();
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let status = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
|
||||
"All files should be committed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_creates_gitignore_with_ds_store() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("new-vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Test\n").unwrap();
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let gitignore = vault.join(".gitignore");
|
||||
assert!(
|
||||
gitignore.exists(),
|
||||
".gitignore should be created by init_repo"
|
||||
);
|
||||
let content = fs::read_to_string(&gitignore).unwrap();
|
||||
assert!(
|
||||
content.contains(".DS_Store"),
|
||||
".gitignore should exclude .DS_Store"
|
||||
);
|
||||
assert!(
|
||||
content.contains(".laputa-cache.json"),
|
||||
".gitignore should exclude .laputa-cache.json"
|
||||
);
|
||||
assert!(
|
||||
content.contains(".laputa/settings.json"),
|
||||
".gitignore should exclude settings.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_does_not_overwrite_existing_gitignore() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("new-vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Test\n").unwrap();
|
||||
fs::write(vault.join(".gitignore"), "custom-rule\n").unwrap();
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let content = fs::read_to_string(vault.join(".gitignore")).unwrap();
|
||||
assert_eq!(
|
||||
content, "custom-rule\n",
|
||||
"existing .gitignore should not be overwritten"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_https() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://github.com/owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://github.com/owner/repo"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_ssh() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("git@github.com:owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_github_repo_path("git@github.com:owner/repo"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_token_embedded() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://gho_abc123@github.com/owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_non_github() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://gitlab.com/owner/repo.git"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
483
src-tauri/src/git/pulse.rs
Normal file
483
src-tauri/src/git/pulse.rs
Normal file
@@ -0,0 +1,483 @@
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::parse_github_repo_path;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct PulseFile {
|
||||
pub path: String,
|
||||
pub status: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct PulseCommit {
|
||||
pub hash: String,
|
||||
#[serde(rename = "shortHash")]
|
||||
pub short_hash: String,
|
||||
pub message: String,
|
||||
pub date: i64,
|
||||
#[serde(rename = "githubUrl")]
|
||||
pub github_url: Option<String>,
|
||||
pub files: Vec<PulseFile>,
|
||||
pub added: usize,
|
||||
pub modified: usize,
|
||||
pub deleted: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct LastCommitInfo {
|
||||
#[serde(rename = "shortHash")]
|
||||
pub short_hash: String,
|
||||
#[serde(rename = "commitUrl")]
|
||||
pub commit_url: Option<String>,
|
||||
}
|
||||
|
||||
fn title_from_path(path: &str) -> String {
|
||||
path.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(path)
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(path)
|
||||
.replace('-', " ")
|
||||
}
|
||||
|
||||
fn parse_file_status(code: &str) -> &str {
|
||||
match code {
|
||||
"A" => "added",
|
||||
"M" => "modified",
|
||||
"D" => "deleted",
|
||||
_ => "modified",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the pulse (commit activity feed) for a vault, showing only .md file changes.
|
||||
/// `skip` offsets into the commit list for pagination; `limit` caps how many to return.
|
||||
pub fn get_vault_pulse(vault_path: &str, limit: usize, skip: usize) -> Result<Vec<PulseCommit>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
if !vault.join(".git").exists() {
|
||||
return Err("Not a git repository".to_string());
|
||||
}
|
||||
|
||||
let limit_str = limit.to_string();
|
||||
let skip_str = skip.to_string();
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"log",
|
||||
"--name-status",
|
||||
"--pretty=format:%H|%h|%s|%aI",
|
||||
"--diff-filter=ADM",
|
||||
"-n",
|
||||
&limit_str,
|
||||
"--skip",
|
||||
&skip_str,
|
||||
"--",
|
||||
"*.md",
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git log: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("does not have any commits yet") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return Err(format!("git log failed: {}", stderr));
|
||||
}
|
||||
|
||||
let github_base = get_github_base_url(vault_path);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(parse_pulse_output(&stdout, &github_base))
|
||||
}
|
||||
|
||||
fn get_github_base_url(vault_path: &str) -> Option<String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let repo_path = parse_github_repo_path(&url)?;
|
||||
Some(format!("https://github.com/{}", repo_path))
|
||||
}
|
||||
|
||||
fn parse_pulse_output(stdout: &str, github_base: &Option<String>) -> Vec<PulseCommit> {
|
||||
let mut commits: Vec<PulseCommit> = Vec::new();
|
||||
let mut current: Option<PulseCommit> = None;
|
||||
|
||||
for line in stdout.lines() {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.contains('|')
|
||||
&& !line.starts_with(|c: char| {
|
||||
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
|
||||
})
|
||||
{
|
||||
// Commit header line: hash|short_hash|message|date
|
||||
if let Some(commit) = current.take() {
|
||||
commits.push(commit);
|
||||
}
|
||||
let parts: Vec<&str> = line.splitn(4, '|').collect();
|
||||
if parts.len() == 4 {
|
||||
let hash = parts[0];
|
||||
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0);
|
||||
let github_url = github_base
|
||||
.as_ref()
|
||||
.map(|base| format!("{}/commit/{}", base, hash));
|
||||
|
||||
current = Some(PulseCommit {
|
||||
hash: hash.to_string(),
|
||||
short_hash: parts[1].to_string(),
|
||||
message: parts[2].to_string(),
|
||||
date,
|
||||
github_url,
|
||||
files: Vec::new(),
|
||||
added: 0,
|
||||
modified: 0,
|
||||
deleted: 0,
|
||||
});
|
||||
}
|
||||
} else if let Some(ref mut commit) = current {
|
||||
// File status line: A\tpath or M\tpath
|
||||
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
|
||||
if file_parts.len() == 2 {
|
||||
let status = parse_file_status(file_parts[0].trim());
|
||||
let path = file_parts[1].trim();
|
||||
match status {
|
||||
"added" => commit.added += 1,
|
||||
"deleted" => commit.deleted += 1,
|
||||
_ => commit.modified += 1,
|
||||
}
|
||||
commit.files.push(PulseFile {
|
||||
path: path.to_string(),
|
||||
status: status.to_string(),
|
||||
title: title_from_path(path),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(commit) = current {
|
||||
commits.push(commit);
|
||||
}
|
||||
|
||||
commits
|
||||
}
|
||||
|
||||
/// Get the last commit's short hash and a GitHub URL (if remote is GitHub).
|
||||
pub fn get_last_commit_info(vault_path: &str) -> Result<Option<LastCommitInfo>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["log", "-1", "--format=%H|%h"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git log: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("does not have any commits yet") {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(format!("git log failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let line = stdout.trim();
|
||||
if line.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.splitn(2, '|').collect();
|
||||
if parts.len() != 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let full_hash = parts[0];
|
||||
let short_hash = parts[1].to_string();
|
||||
|
||||
let commit_url = get_github_commit_url(vault_path, full_hash);
|
||||
|
||||
Ok(Some(LastCommitInfo {
|
||||
short_hash,
|
||||
commit_url,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Try to build a GitHub commit URL from the origin remote URL.
|
||||
fn get_github_commit_url(vault_path: &str, full_hash: &str) -> Option<String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let repo_path = parse_github_repo_path(&url)?;
|
||||
Some(format!(
|
||||
"https://github.com/{}/commit/{}",
|
||||
repo_path, full_hash
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_commit;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_with_commits() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "Add note").unwrap();
|
||||
|
||||
fs::write(vault.join("project.md"), "# Project\n").unwrap();
|
||||
git_commit(vp, "Add project").unwrap();
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
||||
|
||||
assert_eq!(pulse.len(), 2);
|
||||
assert_eq!(pulse[0].message, "Add project");
|
||||
assert_eq!(pulse[1].message, "Add note");
|
||||
assert_eq!(pulse[0].files.len(), 1);
|
||||
assert_eq!(pulse[0].files[0].path, "project.md");
|
||||
assert_eq!(pulse[0].files[0].status, "added");
|
||||
assert_eq!(pulse[0].added, 1);
|
||||
assert_eq!(pulse[0].modified, 0);
|
||||
assert!(!pulse[0].short_hash.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_no_git() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vp = dir.path().to_str().unwrap();
|
||||
|
||||
let result = get_vault_pulse(vp, 30, 0);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Not a git repository"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_empty_repo() {
|
||||
let dir = setup_git_repo();
|
||||
let vp = dir.path().to_str().unwrap();
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
||||
assert!(pulse.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_only_md_files() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
fs::write(vault.join("config.json"), "{}").unwrap();
|
||||
git_commit(vp, "Add files").unwrap();
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
||||
assert_eq!(pulse.len(), 1);
|
||||
assert_eq!(pulse[0].files.len(), 1);
|
||||
assert_eq!(pulse[0].files[0].path, "note.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_respects_limit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
for i in 0..5 {
|
||||
fs::write(
|
||||
vault.join(format!("note{}.md", i)),
|
||||
format!("# Note {}\n", i),
|
||||
)
|
||||
.unwrap();
|
||||
git_commit(vp, &format!("Add note {}", i)).unwrap();
|
||||
}
|
||||
|
||||
let pulse = get_vault_pulse(vp, 3, 0).unwrap();
|
||||
assert_eq!(pulse.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_modified_and_deleted() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "Add note").unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Updated\n").unwrap();
|
||||
git_commit(vp, "Update note").unwrap();
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
||||
assert_eq!(pulse[0].message, "Update note");
|
||||
assert_eq!(pulse[0].files[0].status, "modified");
|
||||
assert_eq!(pulse[0].modified, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_github_url() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "Add note").unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args([
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.com/owner/repo.git",
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
||||
assert!(pulse[0].github_url.is_some());
|
||||
let url = pulse[0].github_url.as_ref().unwrap();
|
||||
assert!(url.starts_with("https://github.com/owner/repo/commit/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_no_github_url_without_remote() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "Add note").unwrap();
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
|
||||
assert!(pulse[0].github_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_from_path() {
|
||||
assert_eq!(title_from_path("note/my-project.md"), "my project");
|
||||
assert_eq!(title_from_path("simple.md"), "simple");
|
||||
assert_eq!(title_from_path("deep/nested/file.md"), "file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pulse_output_basic() {
|
||||
let stdout =
|
||||
"abc123|abc123d|Add notes|2026-03-05T10:00:00+01:00\nA\tnote.md\nM\tproject.md\n";
|
||||
let commits = parse_pulse_output(stdout, &None);
|
||||
|
||||
assert_eq!(commits.len(), 1);
|
||||
assert_eq!(commits[0].message, "Add notes");
|
||||
assert_eq!(commits[0].files.len(), 2);
|
||||
assert_eq!(commits[0].files[0].status, "added");
|
||||
assert_eq!(commits[0].files[1].status, "modified");
|
||||
assert_eq!(commits[0].added, 1);
|
||||
assert_eq!(commits[0].modified, 1);
|
||||
assert!(commits[0].github_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pulse_output_with_github() {
|
||||
let stdout = "abc123|abc123d|Msg|2026-03-05T10:00:00+01:00\nA\tnote.md\n";
|
||||
let base = Some("https://github.com/o/r".to_string());
|
||||
let commits = parse_pulse_output(stdout, &base);
|
||||
|
||||
assert_eq!(
|
||||
commits[0].github_url.as_deref(),
|
||||
Some("https://github.com/o/r/commit/abc123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pulse_output_multiple_commits() {
|
||||
let stdout = "aaa|aaa1234|First|2026-03-05T10:00:00+01:00\nA\ta.md\n\nbbb|bbb1234|Second|2026-03-04T10:00:00+01:00\nM\tb.md\nD\tc.md\n";
|
||||
let commits = parse_pulse_output(stdout, &None);
|
||||
|
||||
assert_eq!(commits.len(), 2);
|
||||
assert_eq!(commits[0].message, "First");
|
||||
assert_eq!(commits[1].message, "Second");
|
||||
assert_eq!(commits[1].files.len(), 2);
|
||||
assert_eq!(commits[1].deleted, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_commit_info_with_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
let info = get_last_commit_info(vp).unwrap();
|
||||
assert!(info.is_some());
|
||||
let info = info.unwrap();
|
||||
assert_eq!(info.short_hash.len(), 7);
|
||||
assert!(info.commit_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_commit_info_no_commits() {
|
||||
let dir = setup_git_repo();
|
||||
let vp = dir.path().to_str().unwrap();
|
||||
|
||||
let info = get_last_commit_info(vp).unwrap();
|
||||
assert!(info.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_commit_info_with_github_remote() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args([
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.com/lucaong/laputa-vault.git",
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let info = get_last_commit_info(vp).unwrap().unwrap();
|
||||
assert!(info.commit_url.is_some());
|
||||
let url = info.commit_url.unwrap();
|
||||
assert!(url.starts_with("https://github.com/lucaong/laputa-vault/commit/"));
|
||||
}
|
||||
}
|
||||
246
src-tauri/src/git/remote.rs
Normal file
246
src-tauri/src/git/remote.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::conflict::get_conflict_files;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitPullResult {
|
||||
pub status: String, // "up_to_date" | "updated" | "conflict" | "no_remote" | "error"
|
||||
pub message: String,
|
||||
#[serde(rename = "updatedFiles")]
|
||||
pub updated_files: Vec<String>,
|
||||
#[serde(rename = "conflictFiles")]
|
||||
pub conflict_files: Vec<String>,
|
||||
}
|
||||
|
||||
/// Check whether the vault repo has at least one remote configured.
|
||||
pub fn has_remote(vault_path: &str) -> Result<bool, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
.args(["remote"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git remote: {}", e))?;
|
||||
|
||||
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
|
||||
}
|
||||
|
||||
/// Pull latest changes from remote. Uses --no-rebase to merge.
|
||||
/// Returns a structured result with status and affected files.
|
||||
pub fn git_pull(vault_path: &str) -> Result<GitPullResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
if !has_remote(vault_path)? {
|
||||
return Ok(GitPullResult {
|
||||
status: "no_remote".to_string(),
|
||||
message: "No remote configured".to_string(),
|
||||
updated_files: vec![],
|
||||
conflict_files: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["pull", "--no-rebase"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git pull: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
|
||||
if output.status.success() {
|
||||
if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
|
||||
return Ok(GitPullResult {
|
||||
status: "up_to_date".to_string(),
|
||||
message: "Already up to date".to_string(),
|
||||
updated_files: vec![],
|
||||
conflict_files: vec![],
|
||||
});
|
||||
}
|
||||
let updated = parse_updated_files(&stdout);
|
||||
return Ok(GitPullResult {
|
||||
status: "updated".to_string(),
|
||||
message: format!("{} file(s) updated", updated.len()),
|
||||
updated_files: updated,
|
||||
conflict_files: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// Check for merge conflicts
|
||||
let conflicts = get_conflict_files(vault_path).unwrap_or_default();
|
||||
if !conflicts.is_empty() {
|
||||
return Ok(GitPullResult {
|
||||
status: "conflict".to_string(),
|
||||
message: format!("Merge conflict in {} file(s)", conflicts.len()),
|
||||
updated_files: vec![],
|
||||
conflict_files: conflicts,
|
||||
});
|
||||
}
|
||||
|
||||
// Network error or other failure — report as error
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout.trim().to_string()
|
||||
} else {
|
||||
stderr.trim().to_string()
|
||||
};
|
||||
Ok(GitPullResult {
|
||||
status: "error".to_string(),
|
||||
message: detail,
|
||||
updated_files: vec![],
|
||||
conflict_files: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `git pull` output to extract updated file paths.
|
||||
fn parse_updated_files(stdout: &str) -> Vec<String> {
|
||||
stdout
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
// Lines like " path/to/file.md | 5 ++-" in diffstat
|
||||
if trimmed.contains('|') {
|
||||
let path = trimmed.split('|').next()?.trim();
|
||||
if !path.is_empty() {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Push to remote.
|
||||
pub fn git_push(vault_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["push"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git push: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git push failed: {}", stderr));
|
||||
}
|
||||
|
||||
// git push often writes to stderr even on success
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Ok(format!("{}{}", stdout, stderr))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_commit;
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_has_remote_returns_false_for_local_repo() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
assert!(!has_remote(vp).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_remote_returns_true_when_remote_exists() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args(["remote", "add", "origin", "https://example.com/repo.git"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(has_remote(vp).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_no_remote_returns_no_remote() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
let result = git_pull(vp).unwrap();
|
||||
assert_eq!(result.status, "no_remote");
|
||||
assert!(result.updated_files.is_empty());
|
||||
assert!(result.conflict_files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_up_to_date() {
|
||||
let (_bare, clone_a, _clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
let result = git_pull(vp_a).unwrap();
|
||||
assert_eq!(result.status, "up_to_date");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_updated_files() {
|
||||
let (_bare, clone_a, clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
git_pull(vp_b).unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Updated Note\n").unwrap();
|
||||
git_commit(vp_a, "update note").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
let result = git_pull(vp_b).unwrap();
|
||||
assert_eq!(result.status, "updated");
|
||||
assert!(result.conflict_files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_updated_files_diffstat() {
|
||||
let stdout =
|
||||
" Fast-forward\n note.md | 2 +-\n project/plan.md | 4 ++--\n 2 files changed\n";
|
||||
let files = parse_updated_files(stdout);
|
||||
assert_eq!(files, vec!["note.md", "project/plan.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_updated_files_empty() {
|
||||
let stdout = "Already up to date.\n";
|
||||
let files = parse_updated_files(stdout);
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_result_serialization() {
|
||||
let result = GitPullResult {
|
||||
status: "updated".to_string(),
|
||||
message: "2 file(s) updated".to_string(),
|
||||
updated_files: vec!["note.md".to_string()],
|
||||
conflict_files: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"updatedFiles\""));
|
||||
assert!(json.contains("\"conflictFiles\""));
|
||||
|
||||
let parsed: GitPullResult = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.status, "updated");
|
||||
assert_eq!(parsed.updated_files.len(), 1);
|
||||
}
|
||||
}
|
||||
175
src-tauri/src/git/status.rs
Normal file
175
src-tauri/src/git/status.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ModifiedFile {
|
||||
pub path: String,
|
||||
#[serde(rename = "relativePath")]
|
||||
pub relative_path: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Get list of modified/added/deleted files in the vault (uncommitted changes).
|
||||
pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain", "--untracked-files=all"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git status failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let files = stdout
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.filter_map(|line| {
|
||||
if line.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let status_code = &line[..2];
|
||||
let path = line[3..].trim().to_string();
|
||||
|
||||
// Only include markdown files
|
||||
if !path.ends_with(".md") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = match status_code.trim() {
|
||||
"M" | "MM" | "AM" => "modified",
|
||||
"A" => "added",
|
||||
"D" => "deleted",
|
||||
"??" => "untracked",
|
||||
"R" | "RM" => "renamed",
|
||||
_ => "modified",
|
||||
};
|
||||
|
||||
let full_path = vault.join(&path).to_string_lossy().to_string();
|
||||
|
||||
Some(ModifiedFile {
|
||||
path: full_path,
|
||||
relative_path: path,
|
||||
status: status.to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_commit;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
// Create and commit a file
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "note.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Add note"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Modify it
|
||||
fs::write(vault.join("note.md"), "# Note\n\nUpdated.").unwrap();
|
||||
// Add an untracked file
|
||||
fs::write(vault.join("new.md"), "# New\n").unwrap();
|
||||
|
||||
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(modified.len() >= 2);
|
||||
let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect();
|
||||
assert!(statuses.contains(&"modified"));
|
||||
assert!(statuses.contains(&"untracked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_untracked_in_subdirectory() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
// Create initial commit so git is initialized
|
||||
fs::write(vault.join("init.md"), "# Init\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "init.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Initial"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Create a new untracked file in a subdirectory (simulates new note creation)
|
||||
fs::create_dir_all(vault.join("note")).unwrap();
|
||||
fs::write(vault.join("note/brand-new.md"), "# Brand New\n").unwrap();
|
||||
|
||||
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(modified.len(), 1);
|
||||
assert_eq!(modified[0].status, "untracked");
|
||||
assert_eq!(modified[0].relative_path, "note/brand-new.md");
|
||||
assert!(
|
||||
modified[0].path.ends_with("/note/brand-new.md"),
|
||||
"Full path should end with relative path: {}",
|
||||
modified[0].path
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_flow_modified_files_then_commit_clears() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
// Create and commit initial file
|
||||
fs::write(vault.join("flow.md"), "# Original\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
// Modify the file on disk
|
||||
fs::write(vault.join("flow.md"), "# Modified\n").unwrap();
|
||||
|
||||
// get_modified_files should detect the change
|
||||
let modified = get_modified_files(vp).unwrap();
|
||||
assert!(
|
||||
modified.iter().any(|f| f.relative_path == "flow.md"),
|
||||
"Modified file should be detected after write"
|
||||
);
|
||||
|
||||
// Commit the change
|
||||
let result = git_commit(vp, "update flow").unwrap();
|
||||
assert!(
|
||||
result.contains("1 file changed") || result.contains("flow.md"),
|
||||
"Commit output should reference the changed file: {}",
|
||||
result
|
||||
);
|
||||
|
||||
// After commit, get_modified_files should return empty
|
||||
let after = get_modified_files(vp).unwrap();
|
||||
assert!(
|
||||
after.is_empty(),
|
||||
"No modified files should remain after commit, found: {:?}",
|
||||
after
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,886 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// GitHub App client ID for OAuth device flow.
|
||||
/// To set up: GitHub Settings → Developer settings → GitHub Apps → New GitHub App.
|
||||
/// Enable "Device authorization flow" under Optional features. Webhook can be disabled.
|
||||
const GITHUB_CLIENT_ID: &str = "Ov23liwee215tDMs9u4L";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GithubRepo {
|
||||
pub name: String,
|
||||
pub full_name: String,
|
||||
pub description: Option<String>,
|
||||
pub private: bool,
|
||||
pub clone_url: String,
|
||||
pub html_url: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Lists the authenticated user's GitHub repositories.
|
||||
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
github_list_repos_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_list_repos_with_base(
|
||||
token: &str,
|
||||
api_base: &str,
|
||||
) -> Result<Vec<GithubRepo>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut all_repos: Vec<GithubRepo> = Vec::new();
|
||||
let mut page = 1u32;
|
||||
|
||||
loop {
|
||||
let url = format!(
|
||||
"{}/user/repos?per_page=100&sort=updated&page={}",
|
||||
api_base, page
|
||||
);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
let repos: Vec<GithubRepo> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
|
||||
|
||||
let count = repos.len();
|
||||
all_repos.extend(repos);
|
||||
|
||||
if count < 100 {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
if page > 10 {
|
||||
break; // safety limit: 1000 repos max
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_repos)
|
||||
}
|
||||
|
||||
/// Creates a new GitHub repository for the authenticated user.
|
||||
pub async fn github_create_repo(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
github_create_repo_with_base(token, name, private, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_create_repo_with_base(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
api_base: &str,
|
||||
) -> Result<GithubRepo, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"private": private,
|
||||
"auto_init": true,
|
||||
"description": "Laputa vault"
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/user/repos", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 422 && body.contains("name already exists") {
|
||||
return Err("Repository name already exists on your account".to_string());
|
||||
}
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<GithubRepo>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))
|
||||
}
|
||||
|
||||
// --- OAuth Device Flow ---
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceFlowStart {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceFlowPollResult {
|
||||
pub status: String,
|
||||
pub access_token: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GitHubUser {
|
||||
pub login: String,
|
||||
pub name: Option<String>,
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
/// Starts the GitHub OAuth device flow. Returns device code info for user authorization.
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
github_device_flow_start_with_base("https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlowStart, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{}/login/device/code", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 404 {
|
||||
return Err(
|
||||
"GitHub device flow not available. Ensure a GitHub App is registered with \
|
||||
'Device authorization flow' enabled (Settings → Developer settings → GitHub Apps)."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
return Err(format!("Device flow start failed ({}): {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<DeviceFlowStart>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse device flow response: {}", e))
|
||||
}
|
||||
|
||||
/// Polls GitHub for the device flow authorization result.
|
||||
pub async fn github_device_flow_poll(device_code: &str) -> Result<DeviceFlowPollResult, String> {
|
||||
github_device_flow_poll_with_base(device_code, "https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_poll_with_base(
|
||||
device_code: &str,
|
||||
base_url: &str,
|
||||
) -> Result<DeviceFlowPollResult, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{}/login/oauth/access_token", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.form(&[
|
||||
("client_id", GITHUB_CLIENT_ID),
|
||||
("device_code", device_code),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow poll failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Device flow poll HTTP error: {}", body));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawResponse {
|
||||
access_token: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
let raw: RawResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse poll response: {}", e))?;
|
||||
|
||||
if let Some(token) = raw.access_token {
|
||||
Ok(DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some(token),
|
||||
error: None,
|
||||
})
|
||||
} else {
|
||||
let error = raw.error.unwrap_or_else(|| "unknown".to_string());
|
||||
let status = match error.as_str() {
|
||||
"authorization_pending" | "slow_down" => "pending",
|
||||
"expired_token" => "expired",
|
||||
_ => "error",
|
||||
};
|
||||
Ok(DeviceFlowPollResult {
|
||||
status: status.to_string(),
|
||||
access_token: None,
|
||||
error: Some(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the authenticated GitHub user's profile.
|
||||
pub async fn github_get_user(token: &str) -> Result<GitHubUser, String> {
|
||||
github_get_user_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_get_user_with_base(token: &str, api_base: &str) -> Result<GitHubUser, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(format!("{}/user", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub user request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<GitHubUser>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse user response: {}", e))
|
||||
}
|
||||
|
||||
/// Clones a GitHub repo to a local path using HTTPS + token auth.
|
||||
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
// Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/...
|
||||
let auth_url = inject_token_into_url(url, token)?;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["clone", "--progress", &auth_url, local_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Clean up partial clone on failure
|
||||
if dest.exists() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git clone failed: {}", stderr));
|
||||
}
|
||||
|
||||
// Configure the remote to use token auth for future pushes
|
||||
configure_remote_auth(local_path, url, token)?;
|
||||
|
||||
Ok(format!("Cloned to {}", local_path))
|
||||
}
|
||||
|
||||
/// Injects an OAuth token into an HTTPS GitHub URL.
|
||||
fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
|
||||
if let Some(rest) = url.strip_prefix("https://github.com/") {
|
||||
Ok(format!("https://oauth2:{}@github.com/{}", token, rest))
|
||||
} else if let Some(rest) = url.strip_prefix("https://") {
|
||||
// Handle URLs that already have a host
|
||||
Ok(format!("https://oauth2:{}@{}", token, rest))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Unsupported URL format: {}. Use an HTTPS URL.",
|
||||
url
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up the git remote to use token-based HTTPS auth.
|
||||
fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> {
|
||||
let auth_url = inject_token_into_url(original_url, token)?;
|
||||
let vault = Path::new(local_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "set-url", "origin", &auth_url])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to configure remote: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to set remote URL: {}", stderr));
|
||||
}
|
||||
|
||||
// Also configure git user if not set
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.email", "laputa@app.local"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.name", "Laputa App"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
// ── Mock helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn mock_json(
|
||||
method: &str,
|
||||
path: &str,
|
||||
status: usize,
|
||||
body: &str,
|
||||
) -> (mockito::ServerGuard, mockito::Mock) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock(method, path)
|
||||
.with_status(status)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.create_async()
|
||||
.await;
|
||||
(server, mock)
|
||||
}
|
||||
|
||||
async fn mock_poll(body: &str) -> DeviceFlowPollResult {
|
||||
let (server, mock) = mock_json("POST", "/login/oauth/access_token", 200, body).await;
|
||||
let result = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
|
||||
.await
|
||||
.unwrap();
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_user(status: usize, body: &str) -> Result<GitHubUser, String> {
|
||||
let (server, mock) = mock_json("GET", "/user", status, body).await;
|
||||
let result = github_get_user_with_base("token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
fn clone_err_contains(url: &str, expected: &str) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("dest");
|
||||
let result = clone_repo(url, "token", dest.to_str().unwrap());
|
||||
assert!(result.unwrap_err().contains(expected));
|
||||
}
|
||||
|
||||
async fn mock_list_repos(status: usize, body: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
let (server, mock) = mock_json(
|
||||
"GET",
|
||||
"/user/repos?per_page=100&sort=updated&page=1",
|
||||
status,
|
||||
body,
|
||||
)
|
||||
.await;
|
||||
let result = github_list_repos_with_base("token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_create_repo(status: usize, body: &str) -> Result<GithubRepo, String> {
|
||||
let (server, mock) = mock_json("POST", "/user/repos", status, body).await;
|
||||
let result = github_create_repo_with_base("token", "repo", false, &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_device_start(status: usize, body: &str) -> Result<DeviceFlowStart, String> {
|
||||
let (server, mock) = mock_json("POST", "/login/device/code", status, body).await;
|
||||
let result = github_device_flow_start_with_base(&server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
// ── Sync/pure logic tests ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_basic_github_url() {
|
||||
let result = inject_token_into_url("https://github.com/user/repo.git", "gho_abc123");
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"https://oauth2:gho_abc123@github.com/user/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_generic_https_url() {
|
||||
let result = inject_token_into_url("https://gitlab.com/user/repo.git", "glpat-abc");
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"https://oauth2:glpat-abc@gitlab.com/user/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_ssh_url_rejected() {
|
||||
let err = inject_token_into_url("git@github.com:user/repo.git", "token").unwrap_err();
|
||||
assert!(err.contains("Unsupported URL format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_http_url_rejected() {
|
||||
assert!(inject_token_into_url("http://github.com/user/repo.git", "token").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_github_without_dot_git() {
|
||||
let result = inject_token_into_url("https://github.com/user/repo", "tok");
|
||||
assert_eq!(result.unwrap(), "https://oauth2:tok@github.com/user/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_nonempty_dest() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("existing.txt"), "data").unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://github.com/test/repo.git",
|
||||
"token",
|
||||
dir.path().to_str().unwrap(),
|
||||
);
|
||||
assert!(result.unwrap_err().contains("not empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_ssh_url_rejected() {
|
||||
clone_err_contains("git@github.com:user/repo.git", "Unsupported URL format");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_empty_dest_allowed() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("empty-dir");
|
||||
std::fs::create_dir(&dest).unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://github.com/nonexistent/repo.git",
|
||||
"token",
|
||||
dest.to_str().unwrap(),
|
||||
);
|
||||
// Should fail at git clone, not at directory check
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_configure_remote_auth_on_git_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args([
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.com/user/repo.git",
|
||||
])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
configure_remote_auth(
|
||||
path.to_str().unwrap(),
|
||||
"https://github.com/user/repo.git",
|
||||
"gho_test123",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = StdCommand::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
assert_eq!(url, "https://oauth2:gho_test123@github.com/user/repo.git");
|
||||
}
|
||||
|
||||
// ── Serialization roundtrip tests ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_serialization_roundtrip() {
|
||||
let repo = GithubRepo {
|
||||
name: "test-repo".to_string(),
|
||||
full_name: "user/test-repo".to_string(),
|
||||
description: Some("A test repo".to_string()),
|
||||
private: true,
|
||||
clone_url: "https://github.com/user/test-repo.git".to_string(),
|
||||
html_url: "https://github.com/user/test-repo".to_string(),
|
||||
updated_at: Some("2026-02-20T10:00:00Z".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&repo).unwrap();
|
||||
assert_eq!(serde_json::from_str::<GithubRepo>(&json).unwrap(), repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_deserialization_null_fields() {
|
||||
let json = r#"{"name":"r","full_name":"u/r","description":null,"private":false,"clone_url":"https://x","html_url":"https://y","updated_at":null}"#;
|
||||
let repo: GithubRepo = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(repo.name, "r");
|
||||
assert!(!repo.private);
|
||||
|
||||
assert!(repo.description.is_none());
|
||||
assert!(repo.updated_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_flow_start_serialization_roundtrip() {
|
||||
let start = DeviceFlowStart {
|
||||
device_code: "dc_123".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
verification_uri: "https://github.com/login/device".to_string(),
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
};
|
||||
let json = serde_json::to_string(&start).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowStart>(&json).unwrap(),
|
||||
start
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_flow_poll_result_roundtrip() {
|
||||
let complete = DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some("gho_abc123".to_string()),
|
||||
error: None,
|
||||
};
|
||||
let json = serde_json::to_string(&complete).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
|
||||
complete
|
||||
);
|
||||
|
||||
let pending = DeviceFlowPollResult {
|
||||
status: "pending".to_string(),
|
||||
access_token: None,
|
||||
error: Some("authorization_pending".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&pending).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
|
||||
pending
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_user_serialization_roundtrip() {
|
||||
let user = GitHubUser {
|
||||
login: "lucaong".to_string(),
|
||||
name: Some("Luca Ongaro".to_string()),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/123".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&user).unwrap();
|
||||
assert_eq!(serde_json::from_str::<GitHubUser>(&json).unwrap(), user);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_user_deserialization_null_name() {
|
||||
let user: GitHubUser =
|
||||
serde_json::from_str(r#"{"login":"bot","name":null,"avatar_url":"https://x"}"#)
|
||||
.unwrap();
|
||||
assert_eq!(user.login, "bot");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
|
||||
// ── HTTP mock: list repos ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_success() {
|
||||
let repos = mock_list_repos(
|
||||
200,
|
||||
r#"[{"name":"my-repo","full_name":"user/my-repo","description":"A repo","private":false,"clone_url":"https://github.com/user/my-repo.git","html_url":"https://github.com/user/my-repo","updated_at":"2026-02-01T00:00:00Z"}]"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repos.len(), 1);
|
||||
assert_eq!(repos[0].name, "my-repo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_empty() {
|
||||
let repos = mock_list_repos(200, "[]").await.unwrap();
|
||||
assert!(repos.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_auth_error() {
|
||||
let err = mock_list_repos(401, r#"{"message":"Bad credentials"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_paginated() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let repos_page1: Vec<serde_json::Value> = (0..100)
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"name": format!("repo-{}", i),
|
||||
"full_name": format!("user/repo-{}", i),
|
||||
"description": null,
|
||||
"private": false,
|
||||
"clone_url": format!("https://github.com/user/repo-{}.git", i),
|
||||
"html_url": format!("https://github.com/user/repo-{}", i),
|
||||
"updated_at": null
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mock1 = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::to_string(&repos_page1).unwrap())
|
||||
.create_async()
|
||||
.await;
|
||||
let mock2 = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=2")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"[{"name":"extra-repo","full_name":"user/extra-repo","description":null,"private":true,"clone_url":"https://github.com/user/extra-repo.git","html_url":"https://github.com/user/extra-repo","updated_at":null}]"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let repos = github_list_repos_with_base("token", &server.url())
|
||||
.await
|
||||
.unwrap();
|
||||
mock1.assert_async().await;
|
||||
mock2.assert_async().await;
|
||||
|
||||
assert_eq!(repos.len(), 101);
|
||||
assert_eq!(repos[100].name, "extra-repo");
|
||||
}
|
||||
|
||||
// ── HTTP mock: create repo ───────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_success() {
|
||||
let repo = mock_create_repo(
|
||||
201,
|
||||
r#"{"name":"new-repo","full_name":"user/new-repo","description":"Laputa vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repo.name, "new-repo");
|
||||
assert!(repo.private);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_name_exists() {
|
||||
let err = mock_create_repo(
|
||||
422,
|
||||
r#"{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("Repository name already exists"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_server_error() {
|
||||
let err = mock_create_repo(500, r#"{"message":"Internal Server Error"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error 500"));
|
||||
}
|
||||
|
||||
// ── HTTP mock: device flow start ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_success() {
|
||||
let start = mock_device_start(
|
||||
200,
|
||||
r#"{"device_code":"dev_abc","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
start,
|
||||
DeviceFlowStart {
|
||||
device_code: "dev_abc".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
verification_uri: "https://github.com/login/device".to_string(),
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_error() {
|
||||
let err = mock_device_start(400, "bad request").await.unwrap_err();
|
||||
assert!(err.contains("Device flow start failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_404_gives_clear_message() {
|
||||
let err = mock_device_start(404, r#"{"error":"Not Found"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("device flow not available"));
|
||||
assert!(err.contains("Device authorization flow"));
|
||||
}
|
||||
|
||||
// ── HTTP mock: device flow poll ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_complete() {
|
||||
let poll =
|
||||
mock_poll(r#"{"access_token":"gho_secret123","token_type":"bearer","scope":"repo"}"#)
|
||||
.await;
|
||||
assert_eq!(
|
||||
poll,
|
||||
DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some("gho_secret123".to_string()),
|
||||
error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_pending() {
|
||||
let poll = mock_poll(
|
||||
r#"{"error":"authorization_pending","error_description":"The authorization request is still pending."}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("authorization_pending".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_slow_down() {
|
||||
let poll = mock_poll(r#"{"error":"slow_down"}"#).await;
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("slow_down".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_expired() {
|
||||
let poll = mock_poll(r#"{"error":"expired_token"}"#).await;
|
||||
assert_eq!(poll.status, "expired");
|
||||
assert_eq!(poll.error, Some("expired_token".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_other_error() {
|
||||
let poll = mock_poll(r#"{"error":"access_denied"}"#).await;
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("access_denied".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_http_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/oauth/access_token")
|
||||
.with_status(503)
|
||||
.with_body("Service Unavailable")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
|
||||
.await
|
||||
.unwrap_err();
|
||||
mock.assert_async().await;
|
||||
assert!(err.contains("Device flow poll HTTP error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_unknown_error() {
|
||||
let poll = mock_poll(r#"{}"#).await;
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("unknown".to_string()));
|
||||
}
|
||||
|
||||
// ── HTTP mock: get user ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_success() {
|
||||
let user = mock_user(
|
||||
200,
|
||||
r#"{"login":"lucaong","name":"Luca Ongaro","avatar_url":"https://avatars.githubusercontent.com/u/12345"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
user,
|
||||
GitHubUser {
|
||||
login: "lucaong".to_string(),
|
||||
name: Some("Luca Ongaro".to_string()),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/12345".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_unauthorized() {
|
||||
let err = mock_user(401, r#"{"message":"Bad credentials"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error 401"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_null_name() {
|
||||
let user = mock_user(
|
||||
200,
|
||||
r#"{"login":"bot-account","name":null,"avatar_url":"https://avatars.githubusercontent.com/u/99"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user.login, "bot-account");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
}
|
||||
326
src-tauri/src/github/api.rs
Normal file
326
src-tauri/src/github/api.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
use super::{GitHubUser, GithubRepo};
|
||||
|
||||
/// Lists the authenticated user's GitHub repositories.
|
||||
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
github_list_repos_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_list_repos_with_base(
|
||||
token: &str,
|
||||
api_base: &str,
|
||||
) -> Result<Vec<GithubRepo>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut all_repos: Vec<GithubRepo> = Vec::new();
|
||||
let mut page = 1u32;
|
||||
|
||||
loop {
|
||||
let url = format!(
|
||||
"{}/user/repos?per_page=100&sort=updated&page={}",
|
||||
api_base, page
|
||||
);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
let repos: Vec<GithubRepo> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
|
||||
|
||||
let count = repos.len();
|
||||
all_repos.extend(repos);
|
||||
|
||||
if count < 100 {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
if page > 10 {
|
||||
break; // safety limit: 1000 repos max
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_repos)
|
||||
}
|
||||
|
||||
/// Creates a new GitHub repository for the authenticated user.
|
||||
pub async fn github_create_repo(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
github_create_repo_with_base(token, name, private, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_create_repo_with_base(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
api_base: &str,
|
||||
) -> Result<GithubRepo, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"private": private,
|
||||
"auto_init": true,
|
||||
"description": "Laputa vault"
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/user/repos", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 422 && body.contains("name already exists") {
|
||||
return Err("Repository name already exists on your account".to_string());
|
||||
}
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<GithubRepo>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))
|
||||
}
|
||||
|
||||
/// Gets the authenticated GitHub user's profile.
|
||||
pub async fn github_get_user(token: &str) -> Result<GitHubUser, String> {
|
||||
github_get_user_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_get_user_with_base(token: &str, api_base: &str) -> Result<GitHubUser, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(format!("{}/user", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub user request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<GitHubUser>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse user response: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn mock_json(
|
||||
method: &str,
|
||||
path: &str,
|
||||
status: usize,
|
||||
body: &str,
|
||||
) -> (mockito::ServerGuard, mockito::Mock) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock(method, path)
|
||||
.with_status(status)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.create_async()
|
||||
.await;
|
||||
(server, mock)
|
||||
}
|
||||
|
||||
async fn mock_list_repos(status: usize, body: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
let (server, mock) = mock_json(
|
||||
"GET",
|
||||
"/user/repos?per_page=100&sort=updated&page=1",
|
||||
status,
|
||||
body,
|
||||
)
|
||||
.await;
|
||||
let result = github_list_repos_with_base("token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_create_repo(status: usize, body: &str) -> Result<GithubRepo, String> {
|
||||
let (server, mock) = mock_json("POST", "/user/repos", status, body).await;
|
||||
let result = github_create_repo_with_base("token", "repo", false, &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_user(status: usize, body: &str) -> Result<GitHubUser, String> {
|
||||
let (server, mock) = mock_json("GET", "/user", status, body).await;
|
||||
let result = github_get_user_with_base("token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_success() {
|
||||
let repos = mock_list_repos(
|
||||
200,
|
||||
r#"[{"name":"my-repo","full_name":"user/my-repo","description":"A repo","private":false,"clone_url":"https://github.com/user/my-repo.git","html_url":"https://github.com/user/my-repo","updated_at":"2026-02-01T00:00:00Z"}]"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repos.len(), 1);
|
||||
assert_eq!(repos[0].name, "my-repo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_empty() {
|
||||
let repos = mock_list_repos(200, "[]").await.unwrap();
|
||||
assert!(repos.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_auth_error() {
|
||||
let err = mock_list_repos(401, r#"{"message":"Bad credentials"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_paginated() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let repos_page1: Vec<serde_json::Value> = (0..100)
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"name": format!("repo-{}", i),
|
||||
"full_name": format!("user/repo-{}", i),
|
||||
"description": null,
|
||||
"private": false,
|
||||
"clone_url": format!("https://github.com/user/repo-{}.git", i),
|
||||
"html_url": format!("https://github.com/user/repo-{}", i),
|
||||
"updated_at": null
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mock1 = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::to_string(&repos_page1).unwrap())
|
||||
.create_async()
|
||||
.await;
|
||||
let mock2 = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=2")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"[{"name":"extra-repo","full_name":"user/extra-repo","description":null,"private":true,"clone_url":"https://github.com/user/extra-repo.git","html_url":"https://github.com/user/extra-repo","updated_at":null}]"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let repos = github_list_repos_with_base("token", &server.url())
|
||||
.await
|
||||
.unwrap();
|
||||
mock1.assert_async().await;
|
||||
mock2.assert_async().await;
|
||||
|
||||
assert_eq!(repos.len(), 101);
|
||||
assert_eq!(repos[100].name, "extra-repo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_success() {
|
||||
let repo = mock_create_repo(
|
||||
201,
|
||||
r#"{"name":"new-repo","full_name":"user/new-repo","description":"Laputa vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repo.name, "new-repo");
|
||||
assert!(repo.private);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_name_exists() {
|
||||
let err = mock_create_repo(
|
||||
422,
|
||||
r#"{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("Repository name already exists"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_server_error() {
|
||||
let err = mock_create_repo(500, r#"{"message":"Internal Server Error"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error 500"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_success() {
|
||||
let user = mock_user(
|
||||
200,
|
||||
r#"{"login":"lucaong","name":"Luca Ongaro","avatar_url":"https://avatars.githubusercontent.com/u/12345"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
user,
|
||||
GitHubUser {
|
||||
login: "lucaong".to_string(),
|
||||
name: Some("Luca Ongaro".to_string()),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/12345".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_unauthorized() {
|
||||
let err = mock_user(401, r#"{"message":"Bad credentials"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error 401"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_null_name() {
|
||||
let user = mock_user(
|
||||
200,
|
||||
r#"{"login":"bot-account","name":null,"avatar_url":"https://avatars.githubusercontent.com/u/99"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user.login, "bot-account");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
}
|
||||
242
src-tauri/src/github/auth.rs
Normal file
242
src-tauri/src/github/auth.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{DeviceFlowPollResult, DeviceFlowStart, GITHUB_CLIENT_ID};
|
||||
|
||||
/// Starts the GitHub OAuth device flow. Returns device code info for user authorization.
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
github_device_flow_start_with_base("https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlowStart, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{}/login/device/code", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 404 {
|
||||
return Err(
|
||||
"GitHub device flow not available. Ensure a GitHub App is registered with \
|
||||
'Device authorization flow' enabled (Settings → Developer settings → GitHub Apps)."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
return Err(format!("Device flow start failed ({}): {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<DeviceFlowStart>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse device flow response: {}", e))
|
||||
}
|
||||
|
||||
/// Polls GitHub for the device flow authorization result.
|
||||
pub async fn github_device_flow_poll(device_code: &str) -> Result<DeviceFlowPollResult, String> {
|
||||
github_device_flow_poll_with_base(device_code, "https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_poll_with_base(
|
||||
device_code: &str,
|
||||
base_url: &str,
|
||||
) -> Result<DeviceFlowPollResult, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{}/login/oauth/access_token", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.form(&[
|
||||
("client_id", GITHUB_CLIENT_ID),
|
||||
("device_code", device_code),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow poll failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Device flow poll HTTP error: {}", body));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawResponse {
|
||||
access_token: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
let raw: RawResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse poll response: {}", e))?;
|
||||
|
||||
if let Some(token) = raw.access_token {
|
||||
Ok(DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some(token),
|
||||
error: None,
|
||||
})
|
||||
} else {
|
||||
let error = raw.error.unwrap_or_else(|| "unknown".to_string());
|
||||
let status = match error.as_str() {
|
||||
"authorization_pending" | "slow_down" => "pending",
|
||||
"expired_token" => "expired",
|
||||
_ => "error",
|
||||
};
|
||||
Ok(DeviceFlowPollResult {
|
||||
status: status.to_string(),
|
||||
access_token: None,
|
||||
error: Some(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn mock_json(
|
||||
method: &str,
|
||||
path: &str,
|
||||
status: usize,
|
||||
body: &str,
|
||||
) -> (mockito::ServerGuard, mockito::Mock) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock(method, path)
|
||||
.with_status(status)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.create_async()
|
||||
.await;
|
||||
(server, mock)
|
||||
}
|
||||
|
||||
async fn mock_device_start(status: usize, body: &str) -> Result<DeviceFlowStart, String> {
|
||||
let (server, mock) = mock_json("POST", "/login/device/code", status, body).await;
|
||||
let result = github_device_flow_start_with_base(&server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_poll(body: &str) -> DeviceFlowPollResult {
|
||||
let (server, mock) = mock_json("POST", "/login/oauth/access_token", 200, body).await;
|
||||
let result = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
|
||||
.await
|
||||
.unwrap();
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_success() {
|
||||
let start = mock_device_start(
|
||||
200,
|
||||
r#"{"device_code":"dev_abc","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
start,
|
||||
DeviceFlowStart {
|
||||
device_code: "dev_abc".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
verification_uri: "https://github.com/login/device".to_string(),
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_error() {
|
||||
let err = mock_device_start(400, "bad request").await.unwrap_err();
|
||||
assert!(err.contains("Device flow start failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_404_gives_clear_message() {
|
||||
let err = mock_device_start(404, r#"{"error":"Not Found"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("device flow not available"));
|
||||
assert!(err.contains("Device authorization flow"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_complete() {
|
||||
let poll =
|
||||
mock_poll(r#"{"access_token":"gho_secret123","token_type":"bearer","scope":"repo"}"#)
|
||||
.await;
|
||||
assert_eq!(
|
||||
poll,
|
||||
DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some("gho_secret123".to_string()),
|
||||
error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_pending() {
|
||||
let poll = mock_poll(
|
||||
r#"{"error":"authorization_pending","error_description":"The authorization request is still pending."}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("authorization_pending".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_slow_down() {
|
||||
let poll = mock_poll(r#"{"error":"slow_down"}"#).await;
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("slow_down".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_expired() {
|
||||
let poll = mock_poll(r#"{"error":"expired_token"}"#).await;
|
||||
assert_eq!(poll.status, "expired");
|
||||
assert_eq!(poll.error, Some("expired_token".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_other_error() {
|
||||
let poll = mock_poll(r#"{"error":"access_denied"}"#).await;
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("access_denied".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_http_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/oauth/access_token")
|
||||
.with_status(503)
|
||||
.with_body("Service Unavailable")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
|
||||
.await
|
||||
.unwrap_err();
|
||||
mock.assert_async().await;
|
||||
assert!(err.contains("Device flow poll HTTP error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_unknown_error() {
|
||||
let poll = mock_poll(r#"{}"#).await;
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("unknown".to_string()));
|
||||
}
|
||||
}
|
||||
203
src-tauri/src/github/clone.rs
Normal file
203
src-tauri/src/github/clone.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Clones a GitHub repo to a local path using HTTPS + token auth.
|
||||
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
// Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/...
|
||||
let auth_url = inject_token_into_url(url, token)?;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["clone", "--progress", &auth_url, local_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Clean up partial clone on failure
|
||||
if dest.exists() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git clone failed: {}", stderr));
|
||||
}
|
||||
|
||||
// Configure the remote to use token auth for future pushes
|
||||
configure_remote_auth(local_path, url, token)?;
|
||||
|
||||
Ok(format!("Cloned to {}", local_path))
|
||||
}
|
||||
|
||||
/// Injects an OAuth token into an HTTPS GitHub URL.
|
||||
fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
|
||||
if let Some(rest) = url.strip_prefix("https://github.com/") {
|
||||
Ok(format!("https://oauth2:{}@github.com/{}", token, rest))
|
||||
} else if let Some(rest) = url.strip_prefix("https://") {
|
||||
// Handle URLs that already have a host
|
||||
Ok(format!("https://oauth2:{}@{}", token, rest))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Unsupported URL format: {}. Use an HTTPS URL.",
|
||||
url
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up the git remote to use token-based HTTPS auth.
|
||||
fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> {
|
||||
let auth_url = inject_token_into_url(original_url, token)?;
|
||||
let vault = Path::new(local_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "set-url", "origin", &auth_url])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to configure remote: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to set remote URL: {}", stderr));
|
||||
}
|
||||
|
||||
// Also configure git user if not set
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.email", "laputa@app.local"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.name", "Laputa App"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
fn clone_err_contains(url: &str, expected: &str) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("dest");
|
||||
let result = clone_repo(url, "token", dest.to_str().unwrap());
|
||||
assert!(result.unwrap_err().contains(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_basic_github_url() {
|
||||
let result = inject_token_into_url("https://github.com/user/repo.git", "gho_abc123");
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"https://oauth2:gho_abc123@github.com/user/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_generic_https_url() {
|
||||
let result = inject_token_into_url("https://gitlab.com/user/repo.git", "glpat-abc");
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"https://oauth2:glpat-abc@gitlab.com/user/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_ssh_url_rejected() {
|
||||
let err = inject_token_into_url("git@github.com:user/repo.git", "token").unwrap_err();
|
||||
assert!(err.contains("Unsupported URL format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_http_url_rejected() {
|
||||
assert!(inject_token_into_url("http://github.com/user/repo.git", "token").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_github_without_dot_git() {
|
||||
let result = inject_token_into_url("https://github.com/user/repo", "tok");
|
||||
assert_eq!(result.unwrap(), "https://oauth2:tok@github.com/user/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_nonempty_dest() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("existing.txt"), "data").unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://github.com/test/repo.git",
|
||||
"token",
|
||||
dir.path().to_str().unwrap(),
|
||||
);
|
||||
assert!(result.unwrap_err().contains("not empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_ssh_url_rejected() {
|
||||
clone_err_contains("git@github.com:user/repo.git", "Unsupported URL format");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_empty_dest_allowed() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("empty-dir");
|
||||
std::fs::create_dir(&dest).unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://github.com/nonexistent/repo.git",
|
||||
"token",
|
||||
dest.to_str().unwrap(),
|
||||
);
|
||||
// Should fail at git clone, not at directory check
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_configure_remote_auth_on_git_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args([
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.com/user/repo.git",
|
||||
])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
configure_remote_auth(
|
||||
path.to_str().unwrap(),
|
||||
"https://github.com/user/repo.git",
|
||||
"gho_test123",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = StdCommand::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
assert_eq!(url, "https://oauth2:gho_test123@github.com/user/repo.git");
|
||||
}
|
||||
}
|
||||
139
src-tauri/src/github/mod.rs
Normal file
139
src-tauri/src/github/mod.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
mod api;
|
||||
mod auth;
|
||||
mod clone;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use api::{github_create_repo, github_get_user, github_list_repos};
|
||||
pub use auth::{github_device_flow_poll, github_device_flow_start};
|
||||
pub use clone::clone_repo;
|
||||
|
||||
/// GitHub App client ID for OAuth device flow.
|
||||
/// To set up: GitHub Settings → Developer settings → GitHub Apps → New GitHub App.
|
||||
/// Enable "Device authorization flow" under Optional features. Webhook can be disabled.
|
||||
const GITHUB_CLIENT_ID: &str = "Ov23liwee215tDMs9u4L";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GithubRepo {
|
||||
pub name: String,
|
||||
pub full_name: String,
|
||||
pub description: Option<String>,
|
||||
pub private: bool,
|
||||
pub clone_url: String,
|
||||
pub html_url: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceFlowStart {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceFlowPollResult {
|
||||
pub status: String,
|
||||
pub access_token: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GitHubUser {
|
||||
pub login: String,
|
||||
pub name: Option<String>,
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_serialization_roundtrip() {
|
||||
let repo = GithubRepo {
|
||||
name: "test-repo".to_string(),
|
||||
full_name: "user/test-repo".to_string(),
|
||||
description: Some("A test repo".to_string()),
|
||||
private: true,
|
||||
clone_url: "https://github.com/user/test-repo.git".to_string(),
|
||||
html_url: "https://github.com/user/test-repo".to_string(),
|
||||
updated_at: Some("2026-02-20T10:00:00Z".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&repo).unwrap();
|
||||
assert_eq!(serde_json::from_str::<GithubRepo>(&json).unwrap(), repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_deserialization_null_fields() {
|
||||
let json = r#"{"name":"r","full_name":"u/r","description":null,"private":false,"clone_url":"https://x","html_url":"https://y","updated_at":null}"#;
|
||||
let repo: GithubRepo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(repo.name, "r");
|
||||
assert!(!repo.private);
|
||||
assert!(repo.description.is_none());
|
||||
assert!(repo.updated_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_flow_start_serialization_roundtrip() {
|
||||
let start = DeviceFlowStart {
|
||||
device_code: "dc_123".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
verification_uri: "https://github.com/login/device".to_string(),
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
};
|
||||
let json = serde_json::to_string(&start).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowStart>(&json).unwrap(),
|
||||
start
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_flow_poll_result_roundtrip() {
|
||||
let complete = DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some("gho_abc123".to_string()),
|
||||
error: None,
|
||||
};
|
||||
let json = serde_json::to_string(&complete).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
|
||||
complete
|
||||
);
|
||||
|
||||
let pending = DeviceFlowPollResult {
|
||||
status: "pending".to_string(),
|
||||
access_token: None,
|
||||
error: Some("authorization_pending".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&pending).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
|
||||
pending
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_user_serialization_roundtrip() {
|
||||
let user = GitHubUser {
|
||||
login: "lucaong".to_string(),
|
||||
name: Some("Luca Ongaro".to_string()),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/123".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&user).unwrap();
|
||||
assert_eq!(serde_json::from_str::<GitHubUser>(&json).unwrap(), user);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_user_deserialization_null_name() {
|
||||
let user: GitHubUser =
|
||||
serde_json::from_str(r#"{"login":"bot","name":null,"avatar_url":"https://x"}"#)
|
||||
.unwrap();
|
||||
assert_eq!(user.login, "bot");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod ai_chat;
|
||||
pub mod claude_cli;
|
||||
mod commands;
|
||||
pub mod frontmatter;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
@@ -13,544 +14,11 @@ pub mod vault;
|
||||
pub mod vault_config;
|
||||
pub mod vault_list;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
use std::process::Child;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use ai_chat::{AiChatRequest, AiChatResponse};
|
||||
use claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent};
|
||||
use frontmatter::FrontmatterValue;
|
||||
use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
|
||||
use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
use indexing::{IndexStatus, IndexingProgress};
|
||||
use search::SearchResponse;
|
||||
use settings::Settings;
|
||||
use theme::{ThemeFile, VaultSettings};
|
||||
use vault::{RenameResult, VaultEntry};
|
||||
use vault_config::VaultConfig;
|
||||
use vault_list::VaultList;
|
||||
|
||||
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
|
||||
/// Returns the original string unchanged if it doesn't start with `~` or if the
|
||||
/// home directory cannot be determined.
|
||||
fn expand_tilde(path: &str) -> Cow<'_, str> {
|
||||
if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(home.to_string_lossy().into_owned());
|
||||
}
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest));
|
||||
}
|
||||
}
|
||||
Cow::Borrowed(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::scan_vault_cached(Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_note_content(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::get_note_content(Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_note_content(path: String, content: String) -> Result<(), String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::save_note_content(&path, &content)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_frontmatter(
|
||||
path: String,
|
||||
key: String,
|
||||
value: FrontmatterValue,
|
||||
) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
frontmatter::update_frontmatter(&path, &key, value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
frontmatter::delete_frontmatter_property(&path, &key)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_history(&vault_path, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_modified_files(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_diff(&vault_path, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_file_diff_at_commit(
|
||||
vault_path: String,
|
||||
path: String,
|
||||
commit_hash: String,
|
||||
) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_vault_pulse(vault_path: String, limit: Option<usize>) -> Result<Vec<PulseCommit>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let limit = limit.unwrap_or(30);
|
||||
git::get_vault_pulse(&vault_path, limit)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_commit(vault_path: String, message: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_commit(&vault_path, &message)
|
||||
}
|
||||
|
||||
fn parse_build_label(version: &str) -> String {
|
||||
let parts: Vec<&str> = version.split('.').collect();
|
||||
match parts.as_slice() {
|
||||
[_, minor, patch] if minor.len() >= 6 => format!("b{}", patch),
|
||||
[_, _, _] => "dev".to_string(),
|
||||
_ => "b?".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_build_number(app_handle: tauri::AppHandle) -> String {
|
||||
let version = app_handle.package_info().version.to_string();
|
||||
parse_build_label(&version)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_last_commit_info(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_pull(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_conflict_files(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_conflict_mode(vault_path: String) -> String {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_conflict_mode(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_resolve_conflict(vault_path: String, file: String, strategy: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_resolve_conflict(&vault_path, &file, &strategy)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_commit_conflict_resolution(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_push(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_push(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
|
||||
ai_chat::send_chat(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn check_claude_cli() -> ClaudeCliStatus {
|
||||
claude_cli::check_cli()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stream_claude_chat(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: ChatStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stream_claude_agent(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: AgentStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-agent-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::save_image(&vault_path, &filename, &data)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::copy_image_to_vault(&vault_path, &source_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn rename_note(
|
||||
vault_path: String,
|
||||
old_path: String,
|
||||
new_title: String,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let old_path = expand_tilde(&old_path);
|
||||
vault::rename_note(&vault_path, &old_path, &new_title)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::purge_trash(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_note(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::delete_note(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::migrate_is_a_to_type(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
|
||||
let path = match target_path {
|
||||
Some(p) if !p.is_empty() => expand_tilde(&p).into_owned(),
|
||||
_ => vault::default_vault_path()?.to_string_lossy().to_string(),
|
||||
};
|
||||
vault::create_getting_started_vault(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn check_vault_exists(path: String) -> bool {
|
||||
let path = expand_tilde(&path);
|
||||
vault::vault_exists(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_default_vault_path() -> Result<String, String> {
|
||||
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(
|
||||
&path,
|
||||
"Trashed at",
|
||||
FrontmatterValue::String(now.clone()),
|
||||
)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_menu_state(
|
||||
app_handle: tauri::AppHandle,
|
||||
has_active_note: bool,
|
||||
has_modified_files: Option<bool>,
|
||||
has_conflicts: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
menu::set_note_items_enabled(&app_handle, has_active_note);
|
||||
if let Some(v) = has_modified_files {
|
||||
menu::set_git_commit_items_enabled(&app_handle, v);
|
||||
}
|
||||
if let Some(v) = has_conflicts {
|
||||
menu::set_git_conflict_items_enabled(&app_handle, v);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_settings() -> Result<Settings, String> {
|
||||
settings::get_settings()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
settings::save_settings(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_vault_list() -> Result<VaultList, String> {
|
||||
vault_list::load_vault_list()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_vault_list(list: VaultList) -> Result<(), String> {
|
||||
vault_list::save_vault_list(&list)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
github::github_list_repos(&token).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_create_repo(
|
||||
token: String,
|
||||
name: String,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
github::github_create_repo(&token, &name, private).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
|
||||
let local_path = expand_tilde(&local_path);
|
||||
github::clone_repo(&url, &token, &local_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
github::github_device_flow_start().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
|
||||
github::github_device_flow_poll(&device_code).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_get_user(token: String) -> Result<GitHubUser, String> {
|
||||
github::github_get_user(&token).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn search_vault(
|
||||
vault_path: String,
|
||||
query: String,
|
||||
mode: String,
|
||||
limit: Option<usize>,
|
||||
) -> Result<SearchResponse, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
let limit = limit.unwrap_or(20);
|
||||
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
|
||||
.await
|
||||
.map_err(|e| format!("Search task failed: {}", e))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_index_status(vault_path: String) -> IndexStatus {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
indexing::check_index_status(&vault_path)
|
||||
}
|
||||
|
||||
fn emit_unavailable(app_handle: &tauri::AppHandle) {
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"indexing-progress",
|
||||
IndexingProgress {
|
||||
phase: "unavailable".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: true,
|
||||
error: Some("qmd not available".to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_indexing(app_handle: tauri::AppHandle, vault_path: String) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if indexing::find_qmd_binary().is_none() {
|
||||
log::info!("qmd binary not found — attempting auto-install via bun");
|
||||
let _ = app_handle.emit(
|
||||
"indexing-progress",
|
||||
IndexingProgress {
|
||||
phase: "installing".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: false,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
match indexing::try_auto_install_qmd() {
|
||||
Ok(()) if indexing::find_qmd_binary().is_some() => {
|
||||
log::info!("qmd auto-installed successfully, proceeding with indexing");
|
||||
}
|
||||
Ok(()) => {
|
||||
log::warn!("qmd auto-install reported success but binary still not found");
|
||||
emit_unavailable(&app_handle);
|
||||
return Err("qmd not available after install".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
log::info!("qmd auto-install failed: {e}");
|
||||
emit_unavailable(&app_handle);
|
||||
return Err(format!("qmd not available: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indexing::run_full_index(&vault_path, |progress| {
|
||||
let _ = app_handle.emit("indexing-progress", &progress);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Indexing task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn trigger_incremental_index(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Incremental index failed: {e}"))?
|
||||
}
|
||||
|
||||
struct WsBridgeChild(Mutex<Option<Child>>);
|
||||
|
||||
#[tauri::command]
|
||||
async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || mcp::register_mcp(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Registration task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_mcp_status() -> Result<mcp::McpStatus, String> {
|
||||
tokio::task::spawn_blocking(mcp::check_mcp_status)
|
||||
.await
|
||||
.map_err(|e| format!("MCP status check failed: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::list_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_theme(vault_path: String, theme_id: String) -> Result<ThemeFile, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::get_theme(&vault_path, &theme_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_vault_settings(vault_path: String) -> Result<VaultSettings, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::get_vault_settings(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_vault_settings(vault_path: String, settings: VaultSettings) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::save_vault_settings(&vault_path, settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_active_theme(vault_path: String, theme_id: Option<String>) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::set_active_theme(&vault_path, theme_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::create_theme(&vault_path, source_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_vault_theme(vault_path: String, name: Option<String>) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::create_vault_theme(&vault_path, name.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn ensure_vault_themes(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::ensure_vault_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restore_default_themes(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::restore_default_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_vault_config(vault_path: String) -> Result<VaultConfig, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault_config::get_vault_config(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_vault_config(vault_path: String, config: VaultConfig) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault_config::save_vault_config(&vault_path, config)
|
||||
}
|
||||
|
||||
fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
match result {
|
||||
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
|
||||
@@ -576,11 +44,17 @@ fn run_startup_tasks() {
|
||||
"Migrated is_a to type on startup",
|
||||
vault::migrate_is_a_to_type(vp_str),
|
||||
);
|
||||
log_startup_result(
|
||||
"Migrated hidden_sections to visible property",
|
||||
vault_config::migrate_hidden_sections_to_visible(vp_str),
|
||||
);
|
||||
|
||||
// Seed _themes/ with built-in JSON themes (legacy) if missing
|
||||
theme::seed_default_themes(vp_str);
|
||||
// Seed theme/ with built-in vault theme notes if missing
|
||||
theme::seed_vault_themes(vp_str);
|
||||
// Seed type/theme.md so the Theme type has an icon in the sidebar
|
||||
let _ = theme::ensure_theme_type_definition(vp_str);
|
||||
|
||||
// Register Laputa MCP server in Claude Code and Cursor configs
|
||||
match mcp::register_mcp(vp_str) {
|
||||
@@ -589,61 +63,6 @@ fn run_startup_tasks() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_with_subpath() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
let result = expand_tilde("~/Documents/vault");
|
||||
assert_eq!(result, format!("{}/Documents/vault", home.display()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_alone() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
let result = expand_tilde("~");
|
||||
assert_eq!(result, home.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_noop_for_absolute_path() {
|
||||
let result = expand_tilde("/usr/local/bin");
|
||||
assert_eq!(result, "/usr/local/bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_noop_for_relative_path() {
|
||||
let result = expand_tilde("some/relative/path");
|
||||
assert_eq!(result, "some/relative/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_noop_for_tilde_in_middle() {
|
||||
let result = expand_tilde("/home/~user/path");
|
||||
assert_eq!(result, "/home/~user/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_release_version() {
|
||||
assert_eq!(parse_build_label("0.20260303.281"), "b281");
|
||||
assert_eq!(parse_build_label("0.20251215.42"), "b42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_dev_version() {
|
||||
assert_eq!(parse_build_label("0.1.0"), "dev");
|
||||
assert_eq!(parse_build_label("0.0.0"), "dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_malformed() {
|
||||
assert_eq!(parse_build_label("invalid"), "b?");
|
||||
assert_eq!(parse_build_label(""), "b?");
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_ws_bridge(app: &mut tauri::App) {
|
||||
use tauri::Manager;
|
||||
let vault_path = dirs::home_dir()
|
||||
@@ -670,11 +89,6 @@ pub fn run() {
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
// Open devtools automatically in debug builds
|
||||
// use tauri::Manager;
|
||||
// if let Some(window) = app.get_webview_window("main") {
|
||||
// window.open_devtools();
|
||||
// }
|
||||
}
|
||||
|
||||
app.handle().plugin(tauri_plugin_dialog::init())?;
|
||||
@@ -693,68 +107,68 @@ pub fn run() {
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_vault,
|
||||
get_note_content,
|
||||
save_note_content,
|
||||
update_frontmatter,
|
||||
delete_frontmatter_property,
|
||||
rename_note,
|
||||
get_file_history,
|
||||
get_modified_files,
|
||||
get_file_diff,
|
||||
get_file_diff_at_commit,
|
||||
get_vault_pulse,
|
||||
git_commit,
|
||||
get_build_number,
|
||||
get_last_commit_info,
|
||||
git_pull,
|
||||
git_push,
|
||||
get_conflict_files,
|
||||
get_conflict_mode,
|
||||
git_resolve_conflict,
|
||||
git_commit_conflict_resolution,
|
||||
ai_chat,
|
||||
check_claude_cli,
|
||||
stream_claude_chat,
|
||||
stream_claude_agent,
|
||||
save_image,
|
||||
copy_image_to_vault,
|
||||
purge_trash,
|
||||
delete_note,
|
||||
migrate_is_a_to_type,
|
||||
batch_archive_notes,
|
||||
batch_trash_notes,
|
||||
get_settings,
|
||||
update_menu_state,
|
||||
save_settings,
|
||||
load_vault_list,
|
||||
save_vault_list,
|
||||
github_list_repos,
|
||||
github_create_repo,
|
||||
clone_repo,
|
||||
github_device_flow_start,
|
||||
github_device_flow_poll,
|
||||
github_get_user,
|
||||
search_vault,
|
||||
get_index_status,
|
||||
start_indexing,
|
||||
trigger_incremental_index,
|
||||
create_getting_started_vault,
|
||||
check_vault_exists,
|
||||
get_default_vault_path,
|
||||
register_mcp_tools,
|
||||
check_mcp_status,
|
||||
list_themes,
|
||||
get_theme,
|
||||
get_vault_settings,
|
||||
save_vault_settings,
|
||||
set_active_theme,
|
||||
create_theme,
|
||||
create_vault_theme,
|
||||
ensure_vault_themes,
|
||||
restore_default_themes,
|
||||
get_vault_config,
|
||||
save_vault_config
|
||||
commands::list_vault,
|
||||
commands::get_note_content,
|
||||
commands::save_note_content,
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
commands::rename_note,
|
||||
commands::get_file_history,
|
||||
commands::get_modified_files,
|
||||
commands::get_file_diff,
|
||||
commands::get_file_diff_at_commit,
|
||||
commands::get_vault_pulse,
|
||||
commands::git_commit,
|
||||
commands::get_build_number,
|
||||
commands::get_last_commit_info,
|
||||
commands::git_pull,
|
||||
commands::git_push,
|
||||
commands::get_conflict_files,
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
commands::git_commit_conflict_resolution,
|
||||
commands::ai_chat,
|
||||
commands::check_claude_cli,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::purge_trash,
|
||||
commands::delete_note,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::batch_archive_notes,
|
||||
commands::batch_trash_notes,
|
||||
commands::get_settings,
|
||||
commands::update_menu_state,
|
||||
commands::save_settings,
|
||||
commands::load_vault_list,
|
||||
commands::save_vault_list,
|
||||
commands::github_list_repos,
|
||||
commands::github_create_repo,
|
||||
commands::clone_repo,
|
||||
commands::github_device_flow_start,
|
||||
commands::github_device_flow_poll,
|
||||
commands::github_get_user,
|
||||
commands::search_vault,
|
||||
commands::get_index_status,
|
||||
commands::start_indexing,
|
||||
commands::trigger_incremental_index,
|
||||
commands::create_getting_started_vault,
|
||||
commands::check_vault_exists,
|
||||
commands::get_default_vault_path,
|
||||
commands::register_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::list_themes,
|
||||
commands::get_theme,
|
||||
commands::get_vault_settings,
|
||||
commands::save_vault_settings,
|
||||
commands::set_active_theme,
|
||||
commands::create_theme,
|
||||
commands::create_vault_theme,
|
||||
commands::ensure_vault_themes,
|
||||
commands::restore_default_themes,
|
||||
commands::get_vault_config,
|
||||
commands::save_vault_config
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
219
src-tauri/src/theme/create.rs
Normal file
219
src-tauri/src/theme/create.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::defaults::DEFAULT_VAULT_THEME_VARS;
|
||||
|
||||
/// Create a new vault theme note in `theme/` directory.
|
||||
/// Returns the absolute path to the newly created theme note.
|
||||
pub fn create_vault_theme(vault_path: &str, name: Option<&str>) -> Result<String, String> {
|
||||
let theme_dir = Path::new(vault_path).join("theme");
|
||||
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
|
||||
|
||||
let display_name = name.unwrap_or("Untitled Theme");
|
||||
let slug = slugify(display_name);
|
||||
let filename = format!("{}.md", find_available_stem(&theme_dir, &slug, "md"));
|
||||
let path = theme_dir.join(&filename);
|
||||
|
||||
let content = vault_theme_note_content(display_name, &DEFAULT_VAULT_THEME_VARS);
|
||||
fs::write(&path, content).map_err(|e| format!("Failed to write theme note: {e}"))?;
|
||||
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Create a new theme file by copying the active theme (or default).
|
||||
/// Returns the ID of the new theme.
|
||||
pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String, String> {
|
||||
let themes_dir = Path::new(vault_path).join("_themes");
|
||||
fs::create_dir_all(&themes_dir)
|
||||
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
|
||||
|
||||
let new_id = find_available_stem(&themes_dir, "untitled", "json");
|
||||
|
||||
let source = source_id.unwrap_or("default");
|
||||
let source_path = themes_dir.join(format!("{source}.json"));
|
||||
|
||||
let content = if source_path.exists() {
|
||||
let mut theme: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(&source_path)
|
||||
.map_err(|e| format!("Failed to read source theme: {e}"))?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to parse source theme: {e}"))?;
|
||||
|
||||
if let Some(obj) = theme.as_object_mut() {
|
||||
obj.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String("Untitled Theme".to_string()),
|
||||
);
|
||||
}
|
||||
serde_json::to_string_pretty(&theme)
|
||||
.map_err(|e| format!("Failed to serialize new theme: {e}"))?
|
||||
} else {
|
||||
default_theme_json("Untitled Theme")
|
||||
};
|
||||
|
||||
fs::write(themes_dir.join(format!("{new_id}.json")), content)
|
||||
.map_err(|e| format!("Failed to write new theme: {e}"))?;
|
||||
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
/// Convert a display name to a URL-safe slug.
|
||||
fn slugify(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// Find an available filename stem (base, base-2, base-3, …) that doesn't
|
||||
/// conflict when `ext` is appended.
|
||||
fn find_available_stem(dir: &Path, base: &str, ext: &str) -> String {
|
||||
if !dir.join(format!("{base}.{ext}")).exists() {
|
||||
return base.to_string();
|
||||
}
|
||||
for i in 2.. {
|
||||
let candidate = format!("{base}-{i}");
|
||||
if !dir.join(format!("{candidate}.{ext}")).exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
/// Build a vault theme note markdown string from a name and CSS variable map.
|
||||
fn vault_theme_note_content(name: &str, vars: &[(&str, &str)]) -> String {
|
||||
let mut fm = format!("---\nIs A: Theme\nDescription: {name} theme\n");
|
||||
for (key, value) in vars {
|
||||
if value.contains('#') || value.contains('\'') || value.contains(',') {
|
||||
fm.push_str(&format!("{key}: \"{value}\"\n"));
|
||||
} else {
|
||||
fm.push_str(&format!("{key}: {value}\n"));
|
||||
}
|
||||
}
|
||||
fm.push_str("---\n\n");
|
||||
fm.push_str(&format!(
|
||||
"# {name} Theme\n\nA custom {name} theme for Laputa.\n"
|
||||
));
|
||||
fm
|
||||
}
|
||||
|
||||
/// Generate the default light theme JSON.
|
||||
fn default_theme_json(name: &str) -> String {
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": name,
|
||||
"description": "Custom theme",
|
||||
"colors": {
|
||||
"background": "#FFFFFF",
|
||||
"foreground": "#37352F",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"accent": "#155DFF",
|
||||
"muted": "#787774",
|
||||
"border": "#E9E9E7"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "system-ui",
|
||||
"font-size-base": "14px"
|
||||
},
|
||||
"spacing": {
|
||||
"sidebar-width": "240px"
|
||||
}
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::theme::defaults::*;
|
||||
use crate::theme::get_theme;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_vault_with_themes(dir: &TempDir) -> String {
|
||||
let vault = dir.path().join("vault");
|
||||
let themes_dir = vault.join("_themes");
|
||||
fs::create_dir_all(&themes_dir).unwrap();
|
||||
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
|
||||
fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap();
|
||||
vault.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_theme_copies_source() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = setup_vault_with_themes(&dir);
|
||||
let new_id = create_theme(&vault, Some("default")).unwrap();
|
||||
assert_eq!(new_id, "untitled");
|
||||
|
||||
let theme = get_theme(&vault, &new_id).unwrap();
|
||||
assert_eq!(theme.name, "Untitled Theme");
|
||||
assert!(!theme.colors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_theme_increments_id() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = setup_vault_with_themes(&dir);
|
||||
|
||||
let id1 = create_theme(&vault, None).unwrap();
|
||||
assert_eq!(id1, "untitled");
|
||||
|
||||
let id2 = create_theme(&vault, None).unwrap();
|
||||
assert_eq!(id2, "untitled-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_theme_creates_md_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
let path = create_vault_theme(vp, Some("My Theme")).unwrap();
|
||||
assert!(std::path::Path::new(&path).exists());
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("Is A: Theme"));
|
||||
assert!(content.contains("# My Theme"));
|
||||
assert!(content.contains("background:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_theme_default_name() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
let path = create_vault_theme(vp, None).unwrap();
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("# Untitled Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_theme_avoids_conflicts() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
let p1 = create_vault_theme(vp, Some("Custom")).unwrap();
|
||||
let p2 = create_vault_theme(vp, Some("Custom")).unwrap();
|
||||
assert_ne!(p1, p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slugify() {
|
||||
assert_eq!(slugify("My Cool Theme"), "my-cool-theme");
|
||||
assert_eq!(slugify("default"), "default");
|
||||
assert_eq!(slugify("Dark Mode!"), "dark-mode");
|
||||
}
|
||||
}
|
||||
343
src-tauri/src/theme/defaults.rs
Normal file
343
src-tauri/src/theme/defaults.rs
Normal file
@@ -0,0 +1,343 @@
|
||||
/// Content for the built-in default (light) theme.
|
||||
pub const DEFAULT_THEME: &str = r##"{
|
||||
"name": "Default",
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"colors": {
|
||||
"background": "#FFFFFF",
|
||||
"foreground": "#37352F",
|
||||
"card": "#FFFFFF",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"destructive": "#E03E3E",
|
||||
"border": "#E9E9E7",
|
||||
"input": "#E9E9E7",
|
||||
"ring": "#155DFF",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-foreground": "#37352F",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-accent": "#EBEBEA"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
},
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
}
|
||||
}"##;
|
||||
|
||||
/// Content for the built-in dark theme.
|
||||
pub const DARK_THEME: &str = r##"{
|
||||
"name": "Dark",
|
||||
"description": "Dark variant with deep navy tones",
|
||||
"colors": {
|
||||
"background": "#0f0f1a",
|
||||
"foreground": "#e0e0e0",
|
||||
"card": "#16162a",
|
||||
"popover": "#1e1e3a",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"secondary": "#2a2a4a",
|
||||
"secondary-foreground": "#e0e0e0",
|
||||
"muted": "#1e1e3a",
|
||||
"muted-foreground": "#888888",
|
||||
"accent": "#2a2a4a",
|
||||
"accent-foreground": "#e0e0e0",
|
||||
"destructive": "#f44336",
|
||||
"border": "#2a2a4a",
|
||||
"input": "#2a2a4a",
|
||||
"ring": "#155DFF",
|
||||
"sidebar-background": "#1a1a2e",
|
||||
"sidebar-foreground": "#e0e0e0",
|
||||
"sidebar-border": "#2a2a4a",
|
||||
"sidebar-accent": "#2a2a4a"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
},
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
}
|
||||
}"##;
|
||||
|
||||
/// Content for the built-in minimal theme.
|
||||
pub const MINIMAL_THEME: &str = r##"{
|
||||
"name": "Minimal",
|
||||
"description": "High contrast, minimal chrome",
|
||||
"colors": {
|
||||
"background": "#FAFAFA",
|
||||
"foreground": "#111111",
|
||||
"card": "#FFFFFF",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#000000",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"secondary": "#F0F0F0",
|
||||
"secondary-foreground": "#111111",
|
||||
"muted": "#F5F5F5",
|
||||
"muted-foreground": "#666666",
|
||||
"accent": "#F0F0F0",
|
||||
"accent-foreground": "#111111",
|
||||
"destructive": "#CC0000",
|
||||
"border": "#E0E0E0",
|
||||
"input": "#E0E0E0",
|
||||
"ring": "#000000",
|
||||
"sidebar-background": "#F5F5F5",
|
||||
"sidebar-foreground": "#111111",
|
||||
"sidebar-border": "#E0E0E0",
|
||||
"sidebar-accent": "#E8E8E8"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'SF Mono', 'Menlo', monospace",
|
||||
"font-size-base": "13px"
|
||||
},
|
||||
"spacing": {
|
||||
"sidebar-width": "220px"
|
||||
}
|
||||
}"##;
|
||||
|
||||
/// CSS variable key-value pairs for the default light vault theme.
|
||||
pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
|
||||
// shadcn/ui base
|
||||
("background", "#FFFFFF"),
|
||||
("foreground", "#37352F"),
|
||||
("card", "#FFFFFF"),
|
||||
("popover", "#FFFFFF"),
|
||||
("primary", "#155DFF"),
|
||||
("primary-foreground", "#FFFFFF"),
|
||||
("secondary", "#EBEBEA"),
|
||||
("secondary-foreground", "#37352F"),
|
||||
("muted", "#F0F0EF"),
|
||||
("muted-foreground", "#787774"),
|
||||
("accent", "#EBEBEA"),
|
||||
("accent-foreground", "#37352F"),
|
||||
("destructive", "#E03E3E"),
|
||||
("border", "#E9E9E7"),
|
||||
("input", "#E9E9E7"),
|
||||
("ring", "#155DFF"),
|
||||
("sidebar", "#F7F6F3"),
|
||||
("sidebar-foreground", "#37352F"),
|
||||
("sidebar-border", "#E9E9E7"),
|
||||
("sidebar-accent", "#EBEBEA"),
|
||||
// Text hierarchy
|
||||
("text-primary", "#37352F"),
|
||||
("text-secondary", "#787774"),
|
||||
("text-muted", "#B4B4B4"),
|
||||
("text-heading", "#37352F"),
|
||||
// Backgrounds
|
||||
("bg-primary", "#FFFFFF"),
|
||||
("bg-sidebar", "#F7F6F3"),
|
||||
("bg-hover", "#EBEBEA"),
|
||||
("bg-hover-subtle", "#F0F0EF"),
|
||||
("bg-selected", "#E8F4FE"),
|
||||
("border-primary", "#E9E9E7"),
|
||||
// Accent colours
|
||||
("accent-blue", "#155DFF"),
|
||||
("accent-green", "#00B38B"),
|
||||
("accent-orange", "#D9730D"),
|
||||
("accent-red", "#E03E3E"),
|
||||
("accent-purple", "#A932FF"),
|
||||
("accent-yellow", "#F0B100"),
|
||||
("accent-blue-light", "#155DFF14"),
|
||||
("accent-green-light", "#00B38B14"),
|
||||
("accent-purple-light", "#A932FF14"),
|
||||
("accent-red-light", "#E03E3E14"),
|
||||
("accent-yellow-light", "#F0B10014"),
|
||||
// Typography
|
||||
(
|
||||
"font-family",
|
||||
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
),
|
||||
("font-size-base", "14px"),
|
||||
// Editor
|
||||
("editor-font-size", "16"),
|
||||
("editor-line-height", "1.5"),
|
||||
("editor-max-width", "720"),
|
||||
];
|
||||
|
||||
/// Vault-based theme note for the built-in Default theme.
|
||||
pub const DEFAULT_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: Light theme with warm, paper-like tones\n\
|
||||
background: \"#FFFFFF\"\n\
|
||||
foreground: \"#37352F\"\n\
|
||||
card: \"#FFFFFF\"\n\
|
||||
popover: \"#FFFFFF\"\n\
|
||||
primary: \"#155DFF\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#EBEBEA\"\n\
|
||||
secondary-foreground: \"#37352F\"\n\
|
||||
muted: \"#F0F0EF\"\n\
|
||||
muted-foreground: \"#787774\"\n\
|
||||
accent: \"#EBEBEA\"\n\
|
||||
accent-foreground: \"#37352F\"\n\
|
||||
destructive: \"#E03E3E\"\n\
|
||||
border: \"#E9E9E7\"\n\
|
||||
input: \"#E9E9E7\"\n\
|
||||
ring: \"#155DFF\"\n\
|
||||
sidebar: \"#F7F6F3\"\n\
|
||||
sidebar-foreground: \"#37352F\"\n\
|
||||
sidebar-border: \"#E9E9E7\"\n\
|
||||
sidebar-accent: \"#EBEBEA\"\n\
|
||||
text-primary: \"#37352F\"\n\
|
||||
text-secondary: \"#787774\"\n\
|
||||
text-muted: \"#B4B4B4\"\n\
|
||||
text-heading: \"#37352F\"\n\
|
||||
bg-primary: \"#FFFFFF\"\n\
|
||||
bg-sidebar: \"#F7F6F3\"\n\
|
||||
bg-hover: \"#EBEBEA\"\n\
|
||||
bg-hover-subtle: \"#F0F0EF\"\n\
|
||||
bg-selected: \"#E8F4FE\"\n\
|
||||
border-primary: \"#E9E9E7\"\n\
|
||||
accent-blue: \"#155DFF\"\n\
|
||||
accent-green: \"#00B38B\"\n\
|
||||
accent-orange: \"#D9730D\"\n\
|
||||
accent-red: \"#E03E3E\"\n\
|
||||
accent-purple: \"#A932FF\"\n\
|
||||
accent-yellow: \"#F0B100\"\n\
|
||||
accent-blue-light: \"#155DFF14\"\n\
|
||||
accent-green-light: \"#00B38B14\"\n\
|
||||
accent-purple-light: \"#A932FF14\"\n\
|
||||
accent-red-light: \"#E03E3E14\"\n\
|
||||
accent-yellow-light: \"#F0B10014\"\n\
|
||||
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
|
||||
font-size-base: 14px\n\
|
||||
editor-font-size: 16\n\
|
||||
editor-line-height: 1.5\n\
|
||||
editor-max-width: 720\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Default Theme\n\
|
||||
\n\
|
||||
The default light theme for Laputa. Clean and warm, inspired by Notion.\n";
|
||||
|
||||
/// Vault-based theme note for the built-in Dark theme.
|
||||
pub const DARK_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: Dark variant with deep navy tones\n\
|
||||
background: \"#0f0f1a\"\n\
|
||||
foreground: \"#e0e0e0\"\n\
|
||||
card: \"#16162a\"\n\
|
||||
popover: \"#1e1e3a\"\n\
|
||||
primary: \"#155DFF\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#2a2a4a\"\n\
|
||||
secondary-foreground: \"#e0e0e0\"\n\
|
||||
muted: \"#1e1e3a\"\n\
|
||||
muted-foreground: \"#888888\"\n\
|
||||
accent: \"#2a2a4a\"\n\
|
||||
accent-foreground: \"#e0e0e0\"\n\
|
||||
destructive: \"#f44336\"\n\
|
||||
border: \"#2a2a4a\"\n\
|
||||
input: \"#2a2a4a\"\n\
|
||||
ring: \"#155DFF\"\n\
|
||||
sidebar: \"#1a1a2e\"\n\
|
||||
sidebar-foreground: \"#e0e0e0\"\n\
|
||||
sidebar-border: \"#2a2a4a\"\n\
|
||||
sidebar-accent: \"#2a2a4a\"\n\
|
||||
text-primary: \"#e0e0e0\"\n\
|
||||
text-secondary: \"#888888\"\n\
|
||||
text-muted: \"#666666\"\n\
|
||||
text-heading: \"#e0e0e0\"\n\
|
||||
bg-primary: \"#0f0f1a\"\n\
|
||||
bg-sidebar: \"#1a1a2e\"\n\
|
||||
bg-hover: \"#2a2a4a\"\n\
|
||||
bg-hover-subtle: \"#1e1e3a\"\n\
|
||||
bg-selected: \"#155DFF22\"\n\
|
||||
border-primary: \"#2a2a4a\"\n\
|
||||
accent-blue: \"#155DFF\"\n\
|
||||
accent-green: \"#00B38B\"\n\
|
||||
accent-orange: \"#D9730D\"\n\
|
||||
accent-red: \"#f44336\"\n\
|
||||
accent-purple: \"#A932FF\"\n\
|
||||
accent-yellow: \"#F0B100\"\n\
|
||||
accent-blue-light: \"#155DFF33\"\n\
|
||||
accent-green-light: \"#00B38B33\"\n\
|
||||
accent-purple-light: \"#A932FF33\"\n\
|
||||
accent-red-light: \"#f4433633\"\n\
|
||||
accent-yellow-light: \"#F0B10033\"\n\
|
||||
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
|
||||
font-size-base: 14px\n\
|
||||
editor-font-size: 16\n\
|
||||
editor-line-height: 1.5\n\
|
||||
editor-max-width: 720\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Dark Theme\n\
|
||||
\n\
|
||||
A dark theme with deep navy tones for comfortable night-time reading.\n";
|
||||
|
||||
/// Vault-based theme note for the built-in Minimal theme.
|
||||
pub const MINIMAL_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: High contrast, minimal chrome\n\
|
||||
background: \"#FAFAFA\"\n\
|
||||
foreground: \"#111111\"\n\
|
||||
card: \"#FFFFFF\"\n\
|
||||
popover: \"#FFFFFF\"\n\
|
||||
primary: \"#000000\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#F0F0F0\"\n\
|
||||
secondary-foreground: \"#111111\"\n\
|
||||
muted: \"#F5F5F5\"\n\
|
||||
muted-foreground: \"#666666\"\n\
|
||||
accent: \"#F0F0F0\"\n\
|
||||
accent-foreground: \"#111111\"\n\
|
||||
destructive: \"#CC0000\"\n\
|
||||
border: \"#E0E0E0\"\n\
|
||||
input: \"#E0E0E0\"\n\
|
||||
ring: \"#000000\"\n\
|
||||
sidebar: \"#F5F5F5\"\n\
|
||||
sidebar-foreground: \"#111111\"\n\
|
||||
sidebar-border: \"#E0E0E0\"\n\
|
||||
sidebar-accent: \"#E8E8E8\"\n\
|
||||
text-primary: \"#111111\"\n\
|
||||
text-secondary: \"#666666\"\n\
|
||||
text-muted: \"#999999\"\n\
|
||||
text-heading: \"#111111\"\n\
|
||||
bg-primary: \"#FAFAFA\"\n\
|
||||
bg-sidebar: \"#F5F5F5\"\n\
|
||||
bg-hover: \"#EBEBEB\"\n\
|
||||
bg-hover-subtle: \"#F5F5F5\"\n\
|
||||
bg-selected: \"#00000014\"\n\
|
||||
border-primary: \"#E0E0E0\"\n\
|
||||
accent-blue: \"#000000\"\n\
|
||||
accent-green: \"#006600\"\n\
|
||||
accent-orange: \"#996600\"\n\
|
||||
accent-red: \"#CC0000\"\n\
|
||||
accent-purple: \"#660099\"\n\
|
||||
accent-yellow: \"#996600\"\n\
|
||||
accent-blue-light: \"#00000014\"\n\
|
||||
accent-green-light: \"#00660014\"\n\
|
||||
accent-purple-light: \"#66009914\"\n\
|
||||
accent-red-light: \"#CC000014\"\n\
|
||||
accent-yellow-light: \"#99660014\"\n\
|
||||
font-family: \"'SF Mono', 'Menlo', monospace\"\n\
|
||||
font-size-base: 13px\n\
|
||||
editor-font-size: 15\n\
|
||||
editor-line-height: 1.6\n\
|
||||
editor-max-width: 680\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Minimal Theme\n\
|
||||
\n\
|
||||
High contrast, minimal chrome. Monospace typography throughout.\n";
|
||||
|
||||
/// Type definition for the Theme note type.
|
||||
pub const THEME_TYPE_DEFINITION: &str = "---\n\
|
||||
Is A: Type\n\
|
||||
icon: palette\n\
|
||||
color: purple\n\
|
||||
order: 50\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Theme\n\
|
||||
\n\
|
||||
A visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n";
|
||||
269
src-tauri/src/theme/mod.rs
Normal file
269
src-tauri/src/theme/mod.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
mod create;
|
||||
pub mod defaults;
|
||||
mod seed;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub use create::{create_theme, create_vault_theme};
|
||||
pub use defaults::*;
|
||||
pub use seed::{
|
||||
ensure_theme_type_definition, ensure_vault_themes, restore_default_themes, seed_default_themes,
|
||||
seed_vault_themes,
|
||||
};
|
||||
|
||||
/// A theme file parsed from _themes/*.json in the vault.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThemeFile {
|
||||
/// Filename stem (e.g. "default" for _themes/default.json)
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub colors: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub typography: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub spacing: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Vault-level settings stored in .laputa/settings.json (git-tracked).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct VaultSettings {
|
||||
#[serde(default)]
|
||||
pub theme: Option<String>,
|
||||
}
|
||||
|
||||
/// List all theme files in _themes/ directory of the vault.
|
||||
/// Seeds built-in themes if the directory is missing.
|
||||
pub fn list_themes(vault_path: &str) -> Result<Vec<ThemeFile>, String> {
|
||||
let themes_dir = Path::new(vault_path).join("_themes");
|
||||
if !themes_dir.is_dir() {
|
||||
seed_default_themes(vault_path);
|
||||
}
|
||||
if !themes_dir.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut themes = Vec::new();
|
||||
let entries =
|
||||
fs::read_dir(&themes_dir).map_err(|e| format!("Failed to read _themes directory: {e}"))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
match parse_theme_file(&path) {
|
||||
Ok(theme) => themes.push(theme),
|
||||
Err(e) => log::warn!("Skipping theme file {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
themes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(themes)
|
||||
}
|
||||
|
||||
/// Parse a single theme JSON file.
|
||||
fn parse_theme_file(path: &Path) -> Result<ThemeFile, String> {
|
||||
let id = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Invalid theme filename".to_string())?;
|
||||
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
|
||||
|
||||
let mut theme: ThemeFile = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
|
||||
|
||||
theme.id = id;
|
||||
Ok(theme)
|
||||
}
|
||||
|
||||
/// Read vault-level settings from .laputa/settings.json.
|
||||
pub fn get_vault_settings(vault_path: &str) -> Result<VaultSettings, String> {
|
||||
let settings_path = Path::new(vault_path).join(".laputa").join("settings.json");
|
||||
if !settings_path.exists() {
|
||||
return Ok(VaultSettings::default());
|
||||
}
|
||||
let content = fs::read_to_string(&settings_path)
|
||||
.map_err(|e| format!("Failed to read vault settings: {e}"))?;
|
||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault settings: {e}"))
|
||||
}
|
||||
|
||||
/// Save vault-level settings to .laputa/settings.json.
|
||||
pub fn save_vault_settings(vault_path: &str, settings: VaultSettings) -> Result<(), String> {
|
||||
let laputa_dir = Path::new(vault_path).join(".laputa");
|
||||
fs::create_dir_all(&laputa_dir)
|
||||
.map_err(|e| format!("Failed to create .laputa directory: {e}"))?;
|
||||
|
||||
let json = serde_json::to_string_pretty(&settings)
|
||||
.map_err(|e| format!("Failed to serialize vault settings: {e}"))?;
|
||||
fs::write(laputa_dir.join("settings.json"), json)
|
||||
.map_err(|e| format!("Failed to write vault settings: {e}"))
|
||||
}
|
||||
|
||||
/// Set the active theme in vault settings. Pass `None` to clear.
|
||||
pub fn set_active_theme(vault_path: &str, theme_id: Option<&str>) -> Result<(), String> {
|
||||
let mut settings = get_vault_settings(vault_path)?;
|
||||
settings.theme = theme_id.map(|s| s.to_string());
|
||||
save_vault_settings(vault_path, settings)
|
||||
}
|
||||
|
||||
/// Read a single theme file by ID from the vault's _themes/ directory.
|
||||
pub fn get_theme(vault_path: &str, theme_id: &str) -> Result<ThemeFile, String> {
|
||||
let path = Path::new(vault_path)
|
||||
.join("_themes")
|
||||
.join(format!("{theme_id}.json"));
|
||||
if !path.exists() {
|
||||
return Err(format!("Theme not found: {theme_id}"));
|
||||
}
|
||||
parse_theme_file(&path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_vault_with_themes(dir: &TempDir) -> String {
|
||||
let vault = dir.path().join("vault");
|
||||
let themes_dir = vault.join("_themes");
|
||||
fs::create_dir_all(&themes_dir).unwrap();
|
||||
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
|
||||
fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap();
|
||||
vault.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_themes_returns_sorted_list() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = setup_vault_with_themes(&dir);
|
||||
let themes = list_themes(&vault).unwrap();
|
||||
assert_eq!(themes.len(), 2);
|
||||
assert_eq!(themes[0].id, "dark");
|
||||
assert_eq!(themes[1].id, "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_themes_seeds_defaults_when_no_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("empty-vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let themes = list_themes(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(themes.len(), 3);
|
||||
let names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect();
|
||||
assert!(names.contains(&"Default"));
|
||||
assert!(names.contains(&"Dark"));
|
||||
assert!(names.contains(&"Minimal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_theme_by_id() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = setup_vault_with_themes(&dir);
|
||||
let theme = get_theme(&vault, "default").unwrap();
|
||||
assert_eq!(theme.name, "Default");
|
||||
assert!(!theme.colors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_theme_not_found() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = setup_vault_with_themes(&dir);
|
||||
let result = get_theme(&vault, "nonexistent");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_settings_roundtrip() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
let settings = get_vault_settings(vp).unwrap();
|
||||
assert!(settings.theme.is_none());
|
||||
|
||||
set_active_theme(vp, Some("dark")).unwrap();
|
||||
let settings = get_vault_settings(vp).unwrap();
|
||||
assert_eq!(settings.theme.as_deref(), Some("dark"));
|
||||
|
||||
set_active_theme(vp, None).unwrap();
|
||||
let settings = get_vault_settings(vp).unwrap();
|
||||
assert_eq!(settings.theme, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_settings_creates_laputa_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
assert!(!vault.join(".laputa").exists());
|
||||
save_vault_settings(
|
||||
vp,
|
||||
VaultSettings {
|
||||
theme: Some("light".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(vault.join(".laputa").join("settings.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_all_builtin_themes() {
|
||||
for (name, content) in [
|
||||
("default", DEFAULT_THEME),
|
||||
("dark", DARK_THEME),
|
||||
("minimal", MINIMAL_THEME),
|
||||
] {
|
||||
let theme: ThemeFile = serde_json::from_str(content)
|
||||
.unwrap_or_else(|e| panic!("Failed to parse {name} theme: {e}"));
|
||||
assert!(!theme.name.is_empty(), "{name} theme should have a name");
|
||||
assert!(!theme.colors.is_empty(), "{name} theme should have colors");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_themes_ignores_non_json_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = setup_vault_with_themes(&dir);
|
||||
let themes_dir = Path::new(&vault).join("_themes");
|
||||
fs::write(themes_dir.join("readme.txt"), "not a theme").unwrap();
|
||||
fs::write(themes_dir.join(".DS_Store"), "").unwrap();
|
||||
|
||||
let themes = list_themes(&vault).unwrap();
|
||||
assert_eq!(themes.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_themes_skips_malformed_json() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = setup_vault_with_themes(&dir);
|
||||
let themes_dir = Path::new(&vault).join("_themes");
|
||||
fs::write(themes_dir.join("broken.json"), "not valid json{{{").unwrap();
|
||||
|
||||
let themes = list_themes(&vault).unwrap();
|
||||
assert_eq!(themes.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_theme_content_contains_all_vars() {
|
||||
let content = DEFAULT_VAULT_THEME;
|
||||
assert!(content.contains("background:"));
|
||||
assert!(content.contains("primary:"));
|
||||
assert!(content.contains("sidebar:"));
|
||||
assert!(content.contains("text-primary:"));
|
||||
assert!(content.contains("accent-blue:"));
|
||||
assert!(content.contains("editor-font-size:"));
|
||||
}
|
||||
}
|
||||
344
src-tauri/src/theme/seed.rs
Normal file
344
src-tauri/src/theme/seed.rs
Normal file
@@ -0,0 +1,344 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::defaults::*;
|
||||
|
||||
/// Create `dir` and write each `(filename, content)` pair if the directory doesn't exist yet.
|
||||
fn seed_dir_with_files(dir: &Path, files: &[(&str, &str)], log_msg: &str) {
|
||||
if dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
if fs::create_dir_all(dir).is_err() {
|
||||
return;
|
||||
}
|
||||
for (name, content) in files {
|
||||
let _ = fs::write(dir.join(name), content);
|
||||
}
|
||||
log::info!("{log_msg}");
|
||||
}
|
||||
|
||||
/// Seed the `_themes/` directory with built-in themes if it doesn't exist yet.
|
||||
/// Safe to call multiple times — only writes files that are missing.
|
||||
pub fn seed_default_themes(vault_path: &str) {
|
||||
seed_dir_with_files(
|
||||
&Path::new(vault_path).join("_themes"),
|
||||
&[
|
||||
("default.json", DEFAULT_THEME),
|
||||
("dark.json", DARK_THEME),
|
||||
("minimal.json", MINIMAL_THEME),
|
||||
],
|
||||
"Seeded _themes/ with built-in themes",
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed the vault `theme/` directory with built-in vault-based theme notes.
|
||||
/// Per-file idempotent: creates the directory if missing, writes each default
|
||||
/// file only when it doesn't exist or is empty (corrupt). Never overwrites
|
||||
/// existing files that have content.
|
||||
pub fn seed_vault_themes(vault_path: &str) {
|
||||
let theme_dir = Path::new(vault_path).join("theme");
|
||||
if fs::create_dir_all(&theme_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let defaults: &[(&str, &str)] = &[
|
||||
("default.md", DEFAULT_VAULT_THEME),
|
||||
("dark.md", DARK_VAULT_THEME),
|
||||
("minimal.md", MINIMAL_VAULT_THEME),
|
||||
];
|
||||
let mut seeded = false;
|
||||
for (name, content) in defaults {
|
||||
let path = theme_dir.join(name);
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
let _ = fs::write(&path, content);
|
||||
seeded = true;
|
||||
}
|
||||
}
|
||||
if seeded {
|
||||
log::info!("Seeded theme/ with built-in vault themes");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure vault theme files exist. Returns an error if the theme directory
|
||||
/// cannot be created (e.g. read-only filesystem).
|
||||
pub fn ensure_vault_themes(vault_path: &str) -> Result<(), String> {
|
||||
let theme_dir = Path::new(vault_path).join("theme");
|
||||
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
|
||||
let defaults: &[(&str, &str)] = &[
|
||||
("default.md", DEFAULT_VAULT_THEME),
|
||||
("dark.md", DARK_VAULT_THEME),
|
||||
("minimal.md", MINIMAL_VAULT_THEME),
|
||||
];
|
||||
for (name, content) in defaults {
|
||||
let path = theme_dir.join(name);
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&path, content).map_err(|e| format!("Failed to write theme/{name}: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore default themes for a vault: seeds both `_themes/` (JSON) and
|
||||
/// `theme/` (markdown notes). Per-file idempotent — never overwrites files
|
||||
/// that already have content. Returns an error on read-only filesystems.
|
||||
pub fn restore_default_themes(vault_path: &str) -> Result<String, String> {
|
||||
// Seed _themes/ JSON files (per-file idempotent)
|
||||
let themes_dir = Path::new(vault_path).join("_themes");
|
||||
fs::create_dir_all(&themes_dir)
|
||||
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
|
||||
let json_defaults: &[(&str, &str)] = &[
|
||||
("default.json", DEFAULT_THEME),
|
||||
("dark.json", DARK_THEME),
|
||||
("minimal.json", MINIMAL_THEME),
|
||||
];
|
||||
for (name, content) in json_defaults {
|
||||
let path = themes_dir.join(name);
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&path, content)
|
||||
.map_err(|e| format!("Failed to write _themes/{name}: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Seed theme/ markdown notes (reuses ensure_vault_themes for consistency)
|
||||
ensure_vault_themes(vault_path)?;
|
||||
|
||||
// Seed type/theme.md so the Theme type has an icon and label in the sidebar
|
||||
ensure_theme_type_definition(vault_path)?;
|
||||
|
||||
Ok("Default themes restored".to_string())
|
||||
}
|
||||
|
||||
/// Create `type/theme.md` if it doesn't exist (gives the Theme type a sidebar icon/color).
|
||||
pub fn ensure_theme_type_definition(vault_path: &str) -> Result<(), String> {
|
||||
let type_dir = Path::new(vault_path).join("type");
|
||||
fs::create_dir_all(&type_dir).map_err(|e| format!("Failed to create type directory: {e}"))?;
|
||||
let path = type_dir.join("theme.md");
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&path, THEME_TYPE_DEFINITION)
|
||||
.map_err(|e| format!("Failed to write type/theme.md: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_creates_theme_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
assert!(!vault.join("theme").exists());
|
||||
seed_vault_themes(vp);
|
||||
assert!(vault.join("theme").is_dir());
|
||||
assert!(vault.join("theme").join("default.md").exists());
|
||||
assert!(vault.join("theme").join("dark.md").exists());
|
||||
assert!(vault.join("theme").join("minimal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
seed_vault_themes(vp);
|
||||
seed_vault_themes(vp); // second call should be a no-op
|
||||
assert!(vault.join("theme").join("default.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_writes_missing_files_in_existing_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
seed_vault_themes(vp);
|
||||
assert!(theme_dir.join("dark.md").exists());
|
||||
assert!(theme_dir.join("minimal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_reseeds_empty_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), "").unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
seed_vault_themes(vp);
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("Is A: Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_preserves_existing_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
let custom = "---\nIs A: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
|
||||
fs::write(theme_dir.join("default.md"), custom).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
seed_vault_themes(vp);
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("#FF0000"),
|
||||
"existing content must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_vault_themes_creates_dir_and_defaults() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
ensure_vault_themes(vp).unwrap();
|
||||
assert!(vault.join("theme").is_dir());
|
||||
assert!(vault.join("theme").join("default.md").exists());
|
||||
assert!(vault.join("theme").join("dark.md").exists());
|
||||
assert!(vault.join("theme").join("minimal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_vault_themes_reseeds_empty_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), "").unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
ensure_vault_themes(vp).unwrap();
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("Is A: Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_vault_themes_preserves_custom_themes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
let custom = "---\nIs A: Theme\nbackground: \"#123456\"\n---\n";
|
||||
fs::write(theme_dir.join("default.md"), custom).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
ensure_vault_themes(vp).unwrap();
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("#123456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_default_themes_creates_both_dirs() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
let msg = restore_default_themes(vp).unwrap();
|
||||
assert_eq!(msg, "Default themes restored");
|
||||
assert!(vault.join("_themes").join("default.json").exists());
|
||||
assert!(vault.join("_themes").join("dark.json").exists());
|
||||
assert!(vault.join("_themes").join("minimal.json").exists());
|
||||
assert!(vault.join("theme").join("default.md").exists());
|
||||
assert!(vault.join("theme").join("dark.md").exists());
|
||||
assert!(vault.join("theme").join("minimal.md").exists());
|
||||
assert!(
|
||||
vault.join("type").join("theme.md").exists(),
|
||||
"restore must create type/theme.md"
|
||||
);
|
||||
let type_content = fs::read_to_string(vault.join("type").join("theme.md")).unwrap();
|
||||
assert!(type_content.contains("Is A: Type"));
|
||||
assert!(type_content.contains("icon: palette"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_theme_type_definition_creates_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
ensure_theme_type_definition(vp).unwrap();
|
||||
let path = vault.join("type").join("theme.md");
|
||||
assert!(path.exists());
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("Is A: Type"));
|
||||
assert!(content.contains("icon: palette"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_theme_type_definition_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let type_dir = vault.join("type");
|
||||
fs::create_dir_all(&type_dir).unwrap();
|
||||
let custom = "---\nIs A: Type\nicon: swatches\ncolor: green\n---\n# Theme\n";
|
||||
fs::write(type_dir.join("theme.md"), custom).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
ensure_theme_type_definition(vp).unwrap();
|
||||
let content = fs::read_to_string(type_dir.join("theme.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("swatches"),
|
||||
"existing content must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_default_themes_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
restore_default_themes(vp).unwrap();
|
||||
let custom = "---\nIs A: Theme\nbackground: \"#CUSTOM\"\n---\n";
|
||||
fs::write(vault.join("theme").join("default.md"), custom).unwrap();
|
||||
|
||||
restore_default_themes(vp).unwrap();
|
||||
let content = fs::read_to_string(vault.join("theme").join("default.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("#CUSTOM"),
|
||||
"must not overwrite existing content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_default_themes_fills_partial_state() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let themes_dir = vault.join("_themes");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&themes_dir).unwrap();
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
restore_default_themes(vp).unwrap();
|
||||
assert!(themes_dir.join("dark.json").exists());
|
||||
assert!(themes_dir.join("minimal.json").exists());
|
||||
assert!(theme_dir.join("dark.md").exists());
|
||||
assert!(theme_dir.join("minimal.md").exists());
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("Light theme with warm"));
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use super::{parse_md_file, scan_vault, VaultEntry};
|
||||
// --- Vault Cache ---
|
||||
|
||||
/// Bump this when VaultEntry fields change to force a full rescan.
|
||||
const CACHE_VERSION: u32 = 4;
|
||||
const CACHE_VERSION: u32 = 5;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
@@ -79,15 +79,10 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
|
||||
.map(|s| collect_md_paths_from_diff(&s))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Use ls-files for untracked files so that newly-seeded directories are picked up
|
||||
// as individual files rather than as a single "?? dirname/" entry.
|
||||
// Include uncommitted changes (modified, staged, and untracked files).
|
||||
let uncommitted = git_uncommitted_files(vault);
|
||||
// Also include modified-but-unstaged files via status --porcelain.
|
||||
let modified = run_git(vault, &["status", "--porcelain"])
|
||||
.map(|s| collect_md_paths_from_porcelain(&s))
|
||||
.unwrap_or_default();
|
||||
|
||||
for path in uncommitted.into_iter().chain(modified) {
|
||||
for path in uncommitted.into_iter() {
|
||||
if !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
@@ -97,9 +92,30 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
|
||||
}
|
||||
|
||||
fn git_uncommitted_files(vault: &Path) -> Vec<String> {
|
||||
run_git(vault, &["status", "--porcelain"])
|
||||
// Modified/staged tracked files from git status --porcelain
|
||||
let mut files: Vec<String> = run_git(vault, &["status", "--porcelain"])
|
||||
.map(|s| collect_md_paths_from_porcelain(&s))
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Untracked files via ls-files (lists individual files, not just directories).
|
||||
// git status --porcelain shows `?? dir/` for new directories, hiding individual
|
||||
// files inside — ls-files resolves them so the cache picks up all new .md files.
|
||||
let untracked = run_git(vault, &["ls-files", "--others", "--exclude-standard"])
|
||||
.map(|s| {
|
||||
s.lines()
|
||||
.filter(|l| !l.is_empty() && l.ends_with(".md"))
|
||||
.map(|l| l.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
for path in untracked {
|
||||
if !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
files
|
||||
}
|
||||
|
||||
fn load_cache(vault: &Path) -> Option<VaultCache> {
|
||||
@@ -140,21 +156,47 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ensure `.laputa-cache.json` is excluded from git via `.git/info/exclude`.
|
||||
/// This prevents the cache (which contains machine-specific absolute paths)
|
||||
/// from being committed and causing stale-path bugs on cloned vaults.
|
||||
/// Machine-local files that should never be git-tracked in any vault.
|
||||
/// These are either caches with absolute paths or per-machine settings.
|
||||
const UNTRACKED_FILES: &[&str] = &[
|
||||
".laputa-cache.json",
|
||||
".laputa/settings.json",
|
||||
];
|
||||
|
||||
/// Ensure machine-local files are excluded from git via `.git/info/exclude`
|
||||
/// and un-tracked if they were previously committed (git rm --cached).
|
||||
/// Called on every cache write so existing vaults self-heal automatically.
|
||||
fn ensure_cache_excluded(vault: &Path) {
|
||||
let exclude_path = vault.join(".git/info/exclude");
|
||||
let entry = ".laputa-cache.json";
|
||||
if let Ok(content) = fs::read_to_string(&exclude_path) {
|
||||
if content.lines().any(|line| line.trim() == entry) {
|
||||
return;
|
||||
}
|
||||
let separator = if content.ends_with('\n') { "" } else { "\n" };
|
||||
let _ = fs::write(&exclude_path, format!("{content}{separator}{entry}\n"));
|
||||
} else if exclude_path.parent().map(|p| p.is_dir()).unwrap_or(false) {
|
||||
let _ = fs::write(&exclude_path, format!("{entry}\n"));
|
||||
let git_dir = vault.join(".git");
|
||||
if !git_dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
let exclude_path = git_dir.join("info").join("exclude");
|
||||
|
||||
// 1. Add each entry to .git/info/exclude so git ignores it going forward.
|
||||
let existing = fs::read_to_string(&exclude_path).unwrap_or_default();
|
||||
let mut to_add: Vec<&str> = UNTRACKED_FILES
|
||||
.iter()
|
||||
.filter(|e| !existing.lines().any(|line| line.trim() == **e))
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
if !to_add.is_empty() {
|
||||
to_add.sort();
|
||||
let separator = if existing.ends_with('\n') || existing.is_empty() { "" } else { "\n" };
|
||||
let additions = to_add.join("\n");
|
||||
let _ = fs::write(&exclude_path, format!("{existing}{separator}{additions}\n"));
|
||||
}
|
||||
|
||||
// 2. Un-track each file if git currently tracks it.
|
||||
// `git rm --cached --quiet --ignore-unmatch` exits 0 even if the file isn't tracked.
|
||||
// This fixes existing vaults where these files were committed before this guard.
|
||||
let _ = std::process::Command::new("git")
|
||||
.args(["rm", "--cached", "--quiet", "--ignore-unmatch", "--"])
|
||||
.args(UNTRACKED_FILES)
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
}
|
||||
|
||||
/// Sort entries by modified_at descending and write the cache.
|
||||
@@ -541,4 +583,66 @@ mod tests {
|
||||
assert!(titles.contains(&"Existing"));
|
||||
assert!(titles.contains(&"New Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_new_files_in_new_subdirectory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
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, "existing.md", "# Existing\n");
|
||||
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();
|
||||
|
||||
// Prime cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
|
||||
// Create files in a new subdirectory (simulates restore_default_themes)
|
||||
create_test_file(
|
||||
vault,
|
||||
"theme/default.md",
|
||||
"---\nIs A: Theme\n---\n# Default Theme\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"theme/dark.md",
|
||||
"---\nIs A: Theme\n---\n# Dark Theme\n",
|
||||
);
|
||||
|
||||
// Cache same commit — files in new subdirectory must appear
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(
|
||||
entries2.len(),
|
||||
3,
|
||||
"must pick up files in new untracked subdirectory"
|
||||
);
|
||||
let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect();
|
||||
assert!(titles.contains(&"Existing"));
|
||||
assert!(titles.contains(&"Default Theme"));
|
||||
assert!(titles.contains(&"Dark Theme"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,10 @@ const SAMPLE_FILES: &[SampleFile] = &[
|
||||
rel_path: "type/topic.md",
|
||||
content: "---\nIs A: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/theme.md",
|
||||
content: "---\nIs A: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "note/welcome-to-laputa.md",
|
||||
content: r#"---
|
||||
@@ -394,7 +398,7 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
|
||||
.map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?;
|
||||
}
|
||||
|
||||
// Seed built-in themes
|
||||
// Seed built-in themes (both JSON legacy and vault-based markdown notes)
|
||||
let themes_dir = vault_dir.join("_themes");
|
||||
fs::create_dir_all(&themes_dir)
|
||||
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
|
||||
@@ -405,6 +409,25 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
|
||||
fs::write(themes_dir.join("minimal.json"), crate::theme::MINIMAL_THEME)
|
||||
.map_err(|e| format!("Failed to write minimal theme: {e}"))?;
|
||||
|
||||
let theme_notes_dir = vault_dir.join("theme");
|
||||
fs::create_dir_all(&theme_notes_dir)
|
||||
.map_err(|e| format!("Failed to create theme directory: {e}"))?;
|
||||
fs::write(
|
||||
theme_notes_dir.join("default.md"),
|
||||
crate::theme::DEFAULT_VAULT_THEME,
|
||||
)
|
||||
.map_err(|e| format!("Failed to write default vault theme: {e}"))?;
|
||||
fs::write(
|
||||
theme_notes_dir.join("dark.md"),
|
||||
crate::theme::DARK_VAULT_THEME,
|
||||
)
|
||||
.map_err(|e| format!("Failed to write dark vault theme: {e}"))?;
|
||||
fs::write(
|
||||
theme_notes_dir.join("minimal.md"),
|
||||
crate::theme::MINIMAL_VAULT_THEME,
|
||||
)
|
||||
.map_err(|e| format!("Failed to write minimal vault theme: {e}"))?;
|
||||
|
||||
crate::git::init_repo(target_path)?;
|
||||
|
||||
Ok(vault_dir
|
||||
@@ -507,8 +530,8 @@ mod tests {
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entries = crate::vault::scan_vault(&vault_path).unwrap();
|
||||
// SAMPLE_FILES + AGENTS.md
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1);
|
||||
// SAMPLE_FILES + AGENTS.md + 3 vault theme notes (theme/default.md, dark.md, minimal.md)
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1 + 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -583,12 +606,21 @@ mod tests {
|
||||
let vault_path = dir.path().join("theme-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
// JSON legacy themes
|
||||
assert!(vault_path.join("_themes/default.json").exists());
|
||||
assert!(vault_path.join("_themes/dark.json").exists());
|
||||
assert!(vault_path.join("_themes/minimal.json").exists());
|
||||
|
||||
let themes = crate::theme::list_themes(vault_path.to_str().unwrap()).unwrap();
|
||||
assert_eq!(themes.len(), 3);
|
||||
|
||||
// Vault-based theme notes
|
||||
assert!(vault_path.join("theme/default.md").exists());
|
||||
assert!(vault_path.join("theme/dark.md").exists());
|
||||
assert!(vault_path.join("theme/minimal.md").exists());
|
||||
|
||||
// Theme type definition
|
||||
assert!(vault_path.join("type/theme.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -14,8 +14,8 @@ pub use rename::{rename_note, RenameResult};
|
||||
pub use trash::{delete_note, purge_trash};
|
||||
|
||||
use parsing::{
|
||||
capitalize_first, contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet,
|
||||
extract_title, parse_iso_date,
|
||||
contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet, extract_title,
|
||||
parse_iso_date, title_case_folder,
|
||||
};
|
||||
|
||||
use gray_matter::engine::YAML;
|
||||
@@ -74,6 +74,8 @@ pub struct VaultEntry {
|
||||
/// Default view mode for the note list when viewing instances of this Type.
|
||||
/// Stored as a string: "all", "editor-list", or "editor-only".
|
||||
pub view: Option<String>,
|
||||
/// Whether this Type is visible in the sidebar. Defaults to true when absent.
|
||||
pub visible: Option<bool>,
|
||||
/// Word count of the note body (excludes frontmatter and H1 title).
|
||||
#[serde(rename = "wordCount")]
|
||||
pub word_count: u32,
|
||||
@@ -128,6 +130,8 @@ struct Frontmatter {
|
||||
sort: Option<String>,
|
||||
#[serde(default)]
|
||||
view: Option<String>,
|
||||
#[serde(default)]
|
||||
visible: Option<bool>,
|
||||
}
|
||||
|
||||
/// Handles YAML fields that can be either a single string or a list of strings.
|
||||
@@ -175,6 +179,7 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"template",
|
||||
"sort",
|
||||
"view",
|
||||
"visible",
|
||||
];
|
||||
|
||||
/// Extract all wikilink-containing fields from raw YAML frontmatter.
|
||||
@@ -274,7 +279,7 @@ fn infer_type_from_folder(folder: &str) -> String {
|
||||
"month" => "Month",
|
||||
"essay" => "Essay",
|
||||
"evergreen" => "Evergreen",
|
||||
_ => return capitalize_first(folder),
|
||||
_ => return title_case_folder(folder),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
@@ -401,6 +406,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
template: frontmatter.template,
|
||||
sort: frontmatter.sort,
|
||||
view: frontmatter.view,
|
||||
visible: frontmatter.visible,
|
||||
word_count,
|
||||
outgoing_links,
|
||||
properties,
|
||||
@@ -960,6 +966,28 @@ References:
|
||||
assert_eq!(entry.is_a, Some("Recipe".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_type_from_hyphenated_folder_title_cases() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let cases = vec![
|
||||
("monday-ideas", "Monday Ideas"),
|
||||
("key-result", "Key Result"),
|
||||
("my_custom_type", "My Custom Type"),
|
||||
("mix-and_match", "Mix And Match"),
|
||||
];
|
||||
for (folder, expected) in cases {
|
||||
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.to_string()),
|
||||
"folder '{}' should infer type '{}'",
|
||||
folder,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_infer_type_frontmatter_overrides_folder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1352,6 +1380,48 @@ Company: Acme Corp
|
||||
assert!(entry.trashed_at.is_none());
|
||||
}
|
||||
|
||||
// --- visible field tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_visible_false_from_type_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
|
||||
let entry = parse_test_entry(&dir, "type/journal.md", content);
|
||||
assert_eq!(entry.visible, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_visible_true_from_type_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nvisible: true\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert_eq!(entry.visible, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_visible_missing_defaults_to_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert_eq!(entry.visible, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visible_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
|
||||
let entry = parse_test_entry(&dir, "type/journal.md", content);
|
||||
assert!(entry.relationships.get("visible").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visible_not_in_properties() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
|
||||
let entry = parse_test_entry(&dir, "type/journal.md", content);
|
||||
assert!(entry.properties.get("visible").is_none());
|
||||
}
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
// save_image tests are in vault/image.rs
|
||||
// purge_trash tests are in vault/trash.rs
|
||||
|
||||
@@ -195,6 +195,16 @@ pub(super) fn capitalize_first(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Title-case a folder name: split on hyphens and underscores, capitalize each word, join with spaces.
|
||||
/// Example: "monday-ideas" → "Monday Ideas", "key_result" → "Key Result"
|
||||
pub(super) fn title_case_folder(s: &str) -> String {
|
||||
s.split(['-', '_'])
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(capitalize_first)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Parse an ISO 8601 date string to Unix timestamp (seconds since epoch).
|
||||
/// Handles "2025-05-23T14:35:00.000Z" and "2025-05-23" formats.
|
||||
pub(super) fn parse_iso_date(date_str: &str) -> Option<u64> {
|
||||
@@ -457,6 +467,29 @@ mod tests {
|
||||
assert_eq!(capitalize_first("a"), "A");
|
||||
}
|
||||
|
||||
// --- title_case_folder tests ---
|
||||
|
||||
#[test]
|
||||
fn test_title_case_folder_hyphenated() {
|
||||
assert_eq!(title_case_folder("monday-ideas"), "Monday Ideas");
|
||||
assert_eq!(title_case_folder("key-result"), "Key Result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_case_folder_underscored() {
|
||||
assert_eq!(title_case_folder("my_custom_type"), "My Custom Type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_case_folder_single_word() {
|
||||
assert_eq!(title_case_folder("recipe"), "Recipe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_case_folder_empty() {
|
||||
assert_eq!(title_case_folder(""), "");
|
||||
}
|
||||
|
||||
// --- without_h1_line tests ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,8 +18,6 @@ pub struct VaultConfig {
|
||||
pub status_colors: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub property_display_modes: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub hidden_sections: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
const CONFIG_DIR: &str = "config";
|
||||
@@ -100,15 +98,6 @@ fn serialize_config(config: &VaultConfig) -> String {
|
||||
"property_display_modes",
|
||||
config.property_display_modes.as_ref(),
|
||||
);
|
||||
if let Some(ref sections) = config.hidden_sections {
|
||||
if !sections.is_empty() {
|
||||
lines.push("hidden_sections:".to_string());
|
||||
for s in sections {
|
||||
lines.push(format!(" - {s}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("---".to_string());
|
||||
lines.join("\n") + "\n"
|
||||
}
|
||||
@@ -149,6 +138,108 @@ fn yaml_safe_value(value: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate `hidden_sections` from `config/ui.config.md` to `visible: false`
|
||||
/// on Type notes. Returns the number of Type notes updated.
|
||||
///
|
||||
/// For each type name in `hidden_sections`:
|
||||
/// - If `type/<slug>.md` exists, adds `visible: false` to its frontmatter
|
||||
/// - If it doesn't exist, creates it with `type: Type`, `title: <name>`, `visible: false`
|
||||
/// - Re-saves the config without `hidden_sections`
|
||||
pub fn migrate_hidden_sections_to_visible(vault_path: &str) -> Result<usize, String> {
|
||||
let path = config_path(vault_path);
|
||||
if !path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let content =
|
||||
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?;
|
||||
|
||||
let hidden = extract_hidden_sections(&content);
|
||||
if hidden.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let type_dir = Path::new(vault_path).join("type");
|
||||
if !type_dir.exists() {
|
||||
std::fs::create_dir_all(&type_dir)
|
||||
.map_err(|e| format!("Failed to create type dir: {e}"))?;
|
||||
}
|
||||
|
||||
let mut migrated = 0;
|
||||
for type_name in &hidden {
|
||||
let slug = type_name_to_slug(type_name);
|
||||
let type_path = type_dir.join(format!("{slug}.md"));
|
||||
|
||||
if type_path.exists() {
|
||||
let type_content = std::fs::read_to_string(&type_path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", type_path.display()))?;
|
||||
if !type_content.contains("visible:") {
|
||||
let updated = crate::frontmatter::update_frontmatter_content(
|
||||
&type_content,
|
||||
"visible",
|
||||
Some(crate::frontmatter::FrontmatterValue::Bool(false)),
|
||||
)
|
||||
.map_err(|e| format!("Failed to update {}: {e}", type_path.display()))?;
|
||||
std::fs::write(&type_path, updated)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", type_path.display()))?;
|
||||
}
|
||||
} else {
|
||||
let new_content = format!(
|
||||
"---\ntype: Type\ntitle: {}\nvisible: false\n---\n\n# {}\n",
|
||||
type_name, type_name
|
||||
);
|
||||
std::fs::write(&type_path, new_content)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", type_path.display()))?;
|
||||
}
|
||||
migrated += 1;
|
||||
}
|
||||
|
||||
// Re-save config without hidden_sections
|
||||
let config = parse_vault_config(&content)?;
|
||||
save_vault_config(vault_path, config)?;
|
||||
|
||||
Ok(migrated)
|
||||
}
|
||||
|
||||
/// Extract `hidden_sections` from raw YAML frontmatter.
|
||||
fn extract_hidden_sections(content: &str) -> Vec<String> {
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(content);
|
||||
|
||||
let hash = match parsed.data {
|
||||
Some(gray_matter::Pod::Hash(map)) => map,
|
||||
_ => return vec![],
|
||||
};
|
||||
|
||||
match hash.get("hidden_sections") {
|
||||
Some(gray_matter::Pod::Array(arr)) => arr
|
||||
.iter()
|
||||
.filter_map(|v| match v {
|
||||
gray_matter::Pod::String(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a Type name to a filesystem slug.
|
||||
fn type_name_to_slug(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// Convert gray_matter::Pod to serde_json::Value.
|
||||
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
|
||||
match pod {
|
||||
@@ -194,9 +285,6 @@ status_colors:
|
||||
Done: blue
|
||||
property_display_modes:
|
||||
deadline: date
|
||||
hidden_sections:
|
||||
- Archived
|
||||
- Trash
|
||||
---
|
||||
"#;
|
||||
let config = parse_vault_config(content).unwrap();
|
||||
@@ -209,8 +297,6 @@ hidden_sections:
|
||||
assert_eq!(statuses.get("Active").unwrap(), "green");
|
||||
let props = config.property_display_modes.unwrap();
|
||||
assert_eq!(props.get("deadline").unwrap(), "date");
|
||||
let hidden = config.hidden_sections.unwrap();
|
||||
assert_eq!(hidden, vec!["Archived", "Trash"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -223,14 +309,12 @@ hidden_sections:
|
||||
tag_colors: Some(tag_colors),
|
||||
status_colors: None,
|
||||
property_display_modes: None,
|
||||
hidden_sections: Some(vec!["Trash".to_string()]),
|
||||
};
|
||||
let serialized = serialize_config(&config);
|
||||
let parsed = parse_vault_config(&serialized).unwrap();
|
||||
assert_eq!(parsed.zoom, Some(1.2));
|
||||
assert_eq!(parsed.view_mode.as_deref(), Some("editor-only"));
|
||||
assert_eq!(parsed.tag_colors.unwrap().get("work").unwrap(), "blue");
|
||||
assert_eq!(parsed.hidden_sections.unwrap(), vec!["Trash"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -252,7 +336,6 @@ hidden_sections:
|
||||
tag_colors: None,
|
||||
status_colors: Some(status_colors),
|
||||
property_display_modes: None,
|
||||
hidden_sections: None,
|
||||
};
|
||||
save_vault_config(vault_path, config).unwrap();
|
||||
|
||||
@@ -264,6 +347,131 @@ hidden_sections:
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_hidden_sections_creates_type_notes_with_visible_false() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
// Create config with hidden_sections
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
"---\ntype: config\nhidden_sections:\n - Bookmark\n - Recipe\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
|
||||
assert_eq!(count, 2);
|
||||
|
||||
// Check type notes were created
|
||||
let bookmark = std::fs::read_to_string(dir.path().join("type/bookmark.md")).unwrap();
|
||||
assert!(bookmark.contains("visible: false"));
|
||||
assert!(bookmark.contains("title: Bookmark"));
|
||||
|
||||
let recipe = std::fs::read_to_string(dir.path().join("type/recipe.md")).unwrap();
|
||||
assert!(recipe.contains("visible: false"));
|
||||
|
||||
// Config should no longer have hidden_sections
|
||||
let config_content = std::fs::read_to_string(config_dir.join("ui.config.md")).unwrap();
|
||||
assert!(!config_content.contains("hidden_sections"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_hidden_sections_updates_existing_type_note() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
// Create config with hidden_sections
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
"---\ntype: config\nhidden_sections:\n - Project\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create existing type note without visible
|
||||
let type_dir = dir.path().join("type");
|
||||
std::fs::create_dir_all(&type_dir).unwrap();
|
||||
std::fs::write(
|
||||
type_dir.join("project.md"),
|
||||
"---\ntype: Type\ntitle: Project\nicon: briefcase\n---\n\n# Project\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let content = std::fs::read_to_string(type_dir.join("project.md")).unwrap();
|
||||
assert!(content.contains("visible: false"));
|
||||
assert!(
|
||||
content.contains("icon: briefcase"),
|
||||
"should preserve existing fields"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_hidden_sections_skips_when_no_hidden_sections() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
"---\ntype: config\nzoom: 1.0\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_hidden_sections_skips_when_no_config_file() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let count = migrate_hidden_sections_to_visible(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_hidden_sections_does_not_duplicate_visible() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
"---\ntype: config\nhidden_sections:\n - Note\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Type note already has visible: false
|
||||
let type_dir = dir.path().join("type");
|
||||
std::fs::create_dir_all(&type_dir).unwrap();
|
||||
std::fs::write(
|
||||
type_dir.join("note.md"),
|
||||
"---\ntype: Type\ntitle: Note\nvisible: false\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let content = std::fs::read_to_string(type_dir.join("note.md")).unwrap();
|
||||
// Should have exactly one visible: false, not two
|
||||
assert_eq!(content.matches("visible:").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_name_to_slug_converts_names() {
|
||||
assert_eq!(type_name_to_slug("Project"), "project");
|
||||
assert_eq!(type_name_to_slug("Weekly Review"), "weekly-review");
|
||||
assert_eq!(type_name_to_slug("My Note!"), "my-note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_safe_key_quoting() {
|
||||
assert_eq!(yaml_safe_key("simple"), "simple");
|
||||
|
||||
@@ -45,6 +45,7 @@ import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import type { SidebarSelection, VaultEntry } from './types'
|
||||
import type { NoteListItem } from './utils/ai-context'
|
||||
import { filterEntries } from './utils/noteListHelpers'
|
||||
import { openLocalFile } from './utils/url'
|
||||
import './App.css'
|
||||
|
||||
// Type declaration for mock content storage
|
||||
@@ -287,8 +288,13 @@ function App() {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
const entry = vault.entries.find(e => e.path === fullPath)
|
||||
if (entry) {
|
||||
// Markdown note — open inside Laputa editor
|
||||
notes.handleSelectNote(entry)
|
||||
dialogs.closeConflictResolver()
|
||||
} else {
|
||||
// Non-note file (e.g. .laputa-cache.json, settings.json) —
|
||||
// open with system default app so the user can inspect/edit it
|
||||
openLocalFile(fullPath)
|
||||
}
|
||||
}
|
||||
}, [resolvedPath, vault.entries, notes, dialogs])
|
||||
@@ -494,7 +500,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
|
||||
@@ -837,7 +837,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
beforeEach(() => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore(
|
||||
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
|
||||
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null },
|
||||
vi.fn(),
|
||||
)
|
||||
initDisplayModeOverrides({})
|
||||
|
||||
@@ -104,11 +104,17 @@ describe('PulseView', () => {
|
||||
expect(nonLink.tagName).toBe('SPAN')
|
||||
})
|
||||
|
||||
it('renders file list with correct titles', async () => {
|
||||
it('renders file list with correct titles when expanded', async () => {
|
||||
mockInvokeFn.mockResolvedValue(mockCommits)
|
||||
|
||||
render(<PulseView vaultPath="/test/vault" />)
|
||||
|
||||
// Files are collapsed by default — expand all commit cards first
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
|
||||
})
|
||||
screen.getAllByLabelText('Expand files').forEach((btn) => fireEvent.click(btn))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('my project')).toBeInTheDocument()
|
||||
})
|
||||
@@ -122,6 +128,12 @@ describe('PulseView', () => {
|
||||
|
||||
render(<PulseView vaultPath="/test/vault" onOpenNote={onOpenNote} />)
|
||||
|
||||
// Expand first commit card
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
|
||||
})
|
||||
fireEvent.click(screen.getAllByLabelText('Expand files')[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('my project')).toBeInTheDocument()
|
||||
})
|
||||
@@ -136,6 +148,12 @@ describe('PulseView', () => {
|
||||
|
||||
render(<PulseView vaultPath="/test/vault" onOpenNote={onOpenNote} />)
|
||||
|
||||
// Expand all commit cards to find the deleted file
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
|
||||
})
|
||||
screen.getAllByLabelText('Expand files').forEach((btn) => fireEvent.click(btn))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('old')).toBeInTheDocument()
|
||||
})
|
||||
@@ -165,19 +183,13 @@ describe('PulseView', () => {
|
||||
expect(screen.getByText('Retry')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Load more button when hasMore is true', async () => {
|
||||
const manyCommits = Array.from({ length: 30 }, (_, i) => ({
|
||||
...mockCommits[0],
|
||||
hash: `hash${i}`,
|
||||
shortHash: `h${i}`,
|
||||
message: `Commit ${i}`,
|
||||
}))
|
||||
mockInvokeFn.mockResolvedValue(manyCommits)
|
||||
it('calls get_vault_pulse with skip=0 on initial load and passes correct page size', async () => {
|
||||
mockInvokeFn.mockResolvedValue([])
|
||||
|
||||
render(<PulseView vaultPath="/test/vault" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Load more')).toBeInTheDocument()
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/test/vault', limit: 20, skip: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -187,24 +199,32 @@ describe('PulseView', () => {
|
||||
render(<PulseView vaultPath="/my/vault" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/my/vault', limit: 30 })
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/my/vault', limit: 20, skip: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles file list visibility when clicking collapse button', async () => {
|
||||
it('toggles file list visibility when clicking expand/collapse button', async () => {
|
||||
mockInvokeFn.mockResolvedValue(mockCommits)
|
||||
|
||||
render(<PulseView vaultPath="/test/vault" />)
|
||||
|
||||
// Files are collapsed by default
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
|
||||
})
|
||||
expect(screen.queryByText('my project')).not.toBeInTheDocument()
|
||||
|
||||
// Click expand on first commit card
|
||||
fireEvent.click(screen.getAllByLabelText('Expand files')[0])
|
||||
|
||||
// Files should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('my project')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click the collapse button on the first commit card
|
||||
const collapseBtn = screen.getAllByLabelText('Collapse files')[0]
|
||||
fireEvent.click(collapseBtn)
|
||||
// Click collapse to hide again
|
||||
fireEvent.click(screen.getAllByLabelText('Collapse files')[0])
|
||||
|
||||
// Files should be hidden
|
||||
expect(screen.queryByText('my project')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, memo } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef, memo } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { PulseCommit, PulseFile } from '../types'
|
||||
@@ -96,7 +96,7 @@ function FileItem({ file, onOpenNote }: { file: PulseFile; onOpenNote?: (path: s
|
||||
}
|
||||
|
||||
function CommitCard({ commit, onOpenNote }: { commit: PulseCommit; onOpenNote?: (path: string) => void }) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const Chevron = expanded ? CaretDown : CaretRight
|
||||
|
||||
return (
|
||||
@@ -207,20 +207,29 @@ function ErrorState({ message, onRetry }: { message: string; onRetry: () => void
|
||||
)
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sidebarCollapsed, onExpandSidebar }: PulseViewProps) {
|
||||
const [commits, setCommits] = useState<PulseCommit[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const batchSize = 30
|
||||
const [skip, setSkip] = useState(0)
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const loadPulse = useCallback(async (limit: number) => {
|
||||
// Initial load
|
||||
const loadInitial = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setCommits([])
|
||||
setSkip(0)
|
||||
setHasMore(true)
|
||||
try {
|
||||
const result = await tauriCall<PulseCommit[]>('get_vault_pulse', { vaultPath, limit })
|
||||
const result = await tauriCall<PulseCommit[]>('get_vault_pulse', { vaultPath, limit: PAGE_SIZE, skip: 0 })
|
||||
setCommits(result)
|
||||
setHasMore(result.length >= limit)
|
||||
setHasMore(result.length >= PAGE_SIZE)
|
||||
setSkip(result.length)
|
||||
} catch (err) {
|
||||
const msg = typeof err === 'string' ? err : 'Failed to load activity'
|
||||
setError(msg)
|
||||
@@ -229,17 +238,40 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
|
||||
}
|
||||
}, [vaultPath])
|
||||
|
||||
useEffect(() => { loadPulse(batchSize) }, [loadPulse])
|
||||
// Append next page
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore || !hasMore) return
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const result = await tauriCall<PulseCommit[]>('get_vault_pulse', { vaultPath, limit: PAGE_SIZE, skip })
|
||||
setCommits((prev) => [...prev, ...result])
|
||||
setHasMore(result.length >= PAGE_SIZE)
|
||||
setSkip((s) => s + result.length)
|
||||
} catch {
|
||||
// silently fail for pagination — user can scroll up/retry
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [vaultPath, skip, loadingMore, hasMore])
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
const nextLimit = commits.length + batchSize
|
||||
loadPulse(nextLimit)
|
||||
}, [commits.length, loadPulse])
|
||||
useEffect(() => { loadInitial() }, [loadInitial])
|
||||
|
||||
// Intersection Observer for infinite scroll
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
if (!sentinel) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => { if (entries[0].isIntersecting) loadMore() },
|
||||
{ threshold: 0.1 },
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [loadMore])
|
||||
|
||||
const dayGroups = groupCommitsByDay(commits)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background">
|
||||
<div className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border" style={{ height: 52, padding: '0 16px' }}>
|
||||
<div className="flex items-center" style={{ gap: 8 }}>
|
||||
@@ -260,12 +292,12 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
|
||||
|
||||
{/* Feed */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && commits.length === 0 ? (
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center" style={{ padding: 32 }}>
|
||||
<span className="text-[13px] text-muted-foreground">Loading activity…</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={() => loadPulse(batchSize)} />
|
||||
<ErrorState message={error} onRetry={loadInitial} />
|
||||
) : commits.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
@@ -278,15 +310,11 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div style={{ padding: '12px 16px' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center justify-center rounded border border-border bg-transparent py-2 text-[12px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
{/* Sentinel for infinite scroll */}
|
||||
<div ref={sentinelRef} style={{ height: 1 }} />
|
||||
{loadingMore && (
|
||||
<div className="flex items-center justify-center" style={{ padding: 12 }}>
|
||||
<span className="text-[12px] text-muted-foreground">Loading…</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
|
||||
|
||||
const mockEntries: VaultEntry[] = [
|
||||
{
|
||||
@@ -676,13 +675,96 @@ describe('Sidebar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('customize section visibility', () => {
|
||||
beforeEach(() => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore(
|
||||
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
|
||||
vi.fn(),
|
||||
)
|
||||
describe('type visibility via visible property', () => {
|
||||
const makeTypeEntry = (title: string, visible: boolean | null): VaultEntry => ({
|
||||
path: `/vault/type/${title.toLowerCase()}.md`,
|
||||
filename: `${title.toLowerCase()}.md`,
|
||||
title,
|
||||
isA: 'Type',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
})
|
||||
|
||||
it('hides a section when its Type entry has visible: false', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
makeTypeEntry('Person', false),
|
||||
]
|
||||
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('People')).not.toBeInTheDocument()
|
||||
// Other sections should still be visible
|
||||
expect(screen.getByText('Projects')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a section when its Type entry has visible: true', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
makeTypeEntry('Person', true),
|
||||
]
|
||||
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('People')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a section when its Type entry has visible: null (default)', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
makeTypeEntry('Person', null),
|
||||
]
|
||||
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('People')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a section when there is no Type entry at all (default visible)', () => {
|
||||
// mockEntries has Person instances but no Type entry for Person
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('People')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides multiple sections when their Type entries have visible: false', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
makeTypeEntry('Person', false),
|
||||
makeTypeEntry('Event', false),
|
||||
]
|
||||
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('People')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Events')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Projects')).toBeInTheDocument()
|
||||
expect(screen.getByText('Topics')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not affect All Notes or other sidebar filters when sections are hidden', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
makeTypeEntry('Project', false),
|
||||
makeTypeEntry('Person', false),
|
||||
]
|
||||
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a "Customize sections" button', () => {
|
||||
@@ -699,74 +781,12 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByLabelText('Toggle Topics')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides a section when its toggle is clicked off', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// People section header should be visible initially
|
||||
expect(screen.getByText('People')).toBeInTheDocument()
|
||||
|
||||
// Open customize popover and toggle People off
|
||||
it('calls onToggleTypeVisibility when toggling a section in the popover', () => {
|
||||
const onToggleTypeVisibility = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onToggleTypeVisibility={onToggleTypeVisibility} />)
|
||||
fireEvent.click(screen.getByTitle('Customize sections'))
|
||||
fireEvent.click(screen.getByLabelText('Toggle People'))
|
||||
|
||||
// People section header should be gone (use getAllByText to handle popover)
|
||||
const peopleElements = screen.queryAllByText('People')
|
||||
// Only the toggle label in the popover should remain, not the section header
|
||||
expect(peopleElements.length).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('re-shows a section when its toggle is clicked on again', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
// Hide People
|
||||
fireEvent.click(screen.getByTitle('Customize sections'))
|
||||
fireEvent.click(screen.getByLabelText('Toggle People'))
|
||||
|
||||
// Show People again
|
||||
fireEvent.click(screen.getByLabelText('Toggle People'))
|
||||
// People section should be visible again — popover toggle + section header = 2 "People" texts
|
||||
const peopleElements = screen.getAllByText('People')
|
||||
expect(peopleElements.length).toBe(2)
|
||||
})
|
||||
|
||||
it('persists hidden sections in vault config', () => {
|
||||
const { unmount } = render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByTitle('Customize sections'))
|
||||
fireEvent.click(screen.getByLabelText('Toggle Events'))
|
||||
unmount()
|
||||
|
||||
// Verify vault config was updated
|
||||
const stored = getVaultConfig().hidden_sections
|
||||
expect(stored).toContain('Event')
|
||||
})
|
||||
|
||||
it('restores hidden sections from vault config on mount', () => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore(
|
||||
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: ['Person', 'Event'] },
|
||||
vi.fn(),
|
||||
)
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
// People and Events section headers should be hidden
|
||||
expect(screen.queryByText('People')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Events')).not.toBeInTheDocument()
|
||||
|
||||
// Other section headers should still be visible
|
||||
expect(screen.getByText('Projects')).toBeInTheDocument()
|
||||
expect(screen.getByText('Topics')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not affect All Notes or other sidebar filters when sections are hidden', () => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore(
|
||||
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: ['Project', 'Person'] },
|
||||
vi.fn(),
|
||||
)
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
// Top nav items still present
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(onToggleTypeVisibility).toHaveBeenCalledWith('Person')
|
||||
})
|
||||
|
||||
it('closes popover when clicking outside', () => {
|
||||
@@ -893,4 +913,42 @@ describe('Sidebar', () => {
|
||||
expect(screen.queryByRole('textbox', { name: 'Section name' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders exactly one section for a hyphenated custom type like Monday Ideas', () => {
|
||||
const entriesWithMondayIdeas: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
{
|
||||
path: '/vault/monday-ideas/standup-bingo.md',
|
||||
filename: 'standup-bingo.md',
|
||||
title: 'Standup Bingo',
|
||||
isA: 'Monday Ideas',
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 310, snippet: '', wordCount: 120,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/monday-ideas/theme-days.md',
|
||||
filename: 'theme-days.md',
|
||||
title: 'Theme Days',
|
||||
isA: 'Monday Ideas',
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 280, snippet: '', wordCount: 95,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={entriesWithMondayIdeas} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// "Monday Ideas" pluralized → "Monday Ideases" (the pluralizeType function)
|
||||
const mondaySections = screen.getAllByText(/Monday Ideas/i)
|
||||
expect(mondaySections).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,6 @@ import { resolveIcon } from '../utils/iconRegistry'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { pluralizeType } from '../hooks/useCommandRegistry'
|
||||
import { TypeCustomizePopover } from './TypeCustomizePopover'
|
||||
import { useSectionVisibility } from '../hooks/useSectionVisibility'
|
||||
import {
|
||||
DndContext, closestCenter, KeyboardSensor, PointerSensor,
|
||||
useSensor, useSensors, type DragEndEvent,
|
||||
@@ -35,6 +34,7 @@ interface SidebarProps {
|
||||
onUpdateTypeTemplate?: (typeName: string, template: string) => void
|
||||
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
|
||||
onRenameSection?: (typeName: string, label: string) => void
|
||||
onToggleTypeVisibility?: (typeName: string) => void
|
||||
modifiedCount?: number
|
||||
onCommitPush?: () => void
|
||||
onCollapse?: () => void
|
||||
@@ -104,13 +104,13 @@ function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, Vault
|
||||
})
|
||||
}
|
||||
|
||||
function useSidebarSections(entries: VaultEntry[], isSectionVisible: (type: string) => boolean) {
|
||||
function useSidebarSections(entries: VaultEntry[]) {
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const allSectionGroups = useMemo(() => {
|
||||
const sections = buildDynamicSections(entries, typeEntryMap)
|
||||
return sortSections(sections, typeEntryMap)
|
||||
}, [entries, typeEntryMap])
|
||||
const visibleSections = useMemo(() => allSectionGroups.filter((g) => isSectionVisible(g.type)), [allSectionGroups, isSectionVisible])
|
||||
const visibleSections = useMemo(() => allSectionGroups.filter((g) => typeEntryMap[g.type]?.visible !== false), [allSectionGroups, typeEntryMap])
|
||||
const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections])
|
||||
return { typeEntryMap, allSectionGroups, visibleSections, sectionIds }
|
||||
}
|
||||
@@ -271,6 +271,7 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
|
||||
onToggleTypeVisibility,
|
||||
modifiedCount = 0, onCommitPush, onCollapse, isGitVault = false,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
@@ -285,8 +286,10 @@ export const Sidebar = memo(function Sidebar({
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const customizeRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { toggleSection: toggleVisibility, isSectionVisible } = useSectionVisibility()
|
||||
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries, isSectionVisible)
|
||||
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries)
|
||||
|
||||
const isSectionVisible = useCallback((type: string) => typeEntryMap[type]?.visible !== false, [typeEntryMap])
|
||||
const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility])
|
||||
const { activeCount, archivedCount, trashedCount } = useEntryCounts(entries)
|
||||
|
||||
const closeContextMenu = useCallback(() => { setContextMenuPos(null); setContextMenuType(null) }, [])
|
||||
|
||||
@@ -289,4 +289,42 @@ describe('useEntryActions', () => {
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleToggleTypeVisibility', () => {
|
||||
it('sets visible to false when currently visible (null/default)', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/type/journal.md', visible: null })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleTypeVisibility('Journal')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/journal.md', 'visible', false)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: false })
|
||||
})
|
||||
|
||||
it('sets visible to true (deletes property) when currently hidden', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/type/journal.md', visible: false })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleTypeVisibility('Journal')
|
||||
})
|
||||
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/type/journal.md', 'visible')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: null })
|
||||
})
|
||||
|
||||
it('auto-creates type entry when not found', async () => {
|
||||
const { result } = setup([])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleTypeVisibility('Journal')
|
||||
})
|
||||
|
||||
expect(createTypeEntry).toHaveBeenCalledWith('Journal')
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/journal.md', 'visible', false)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: false })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,5 +80,17 @@ export function useEntryActions({
|
||||
}
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry])
|
||||
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection }
|
||||
const handleToggleTypeVisibility = useCallback(async (typeName: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
if (typeEntry.visible === false) {
|
||||
updateEntry(typeEntry.path, { visible: null })
|
||||
await handleDeleteProperty(typeEntry.path, 'visible')
|
||||
} else {
|
||||
updateEntry(typeEntry.path, { visible: false })
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'visible', false)
|
||||
}
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry])
|
||||
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility }
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: now, createdAt: now, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, properties: {},
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { getVaultConfig, updateVaultConfigField, subscribeVaultConfig } from '../utils/vaultConfigStore'
|
||||
|
||||
function loadHiddenSections(): Set<string> {
|
||||
const fromConfig = getVaultConfig().hidden_sections
|
||||
if (fromConfig && fromConfig.length > 0) return new Set(fromConfig)
|
||||
// Fallback to localStorage during initial load
|
||||
try {
|
||||
const raw = localStorage.getItem('laputa-hidden-sections')
|
||||
if (raw) {
|
||||
const arr = JSON.parse(raw)
|
||||
if (Array.isArray(arr)) return new Set(arr as string[])
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return new Set()
|
||||
}
|
||||
|
||||
function saveHiddenSections(hidden: Set<string>): void {
|
||||
const arr = [...hidden]
|
||||
updateVaultConfigField('hidden_sections', arr.length > 0 ? arr : null)
|
||||
}
|
||||
|
||||
export function useSectionVisibility() {
|
||||
const [hiddenSections, setHiddenSections] = useState<Set<string>>(loadHiddenSections)
|
||||
|
||||
// Re-sync when vault config becomes available
|
||||
useEffect(() => {
|
||||
return subscribeVaultConfig(() => {
|
||||
const sections = getVaultConfig().hidden_sections
|
||||
if (sections) setHiddenSections(new Set(sections))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleSection = useCallback((type: string) => {
|
||||
setHiddenSections((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(type)) {
|
||||
next.delete(type)
|
||||
} else {
|
||||
next.add(type)
|
||||
}
|
||||
saveHiddenSections(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isSectionVisible = useCallback(
|
||||
(type: string) => !hiddenSections.has(type),
|
||||
[hiddenSections],
|
||||
)
|
||||
|
||||
return { hiddenSections, toggleSection, isSectionVisible }
|
||||
}
|
||||
@@ -11,7 +11,7 @@ describe('useViewMode', () => {
|
||||
beforeEach(() => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore(
|
||||
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
|
||||
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null },
|
||||
vi.fn(),
|
||||
)
|
||||
})
|
||||
@@ -26,7 +26,7 @@ describe('useViewMode', () => {
|
||||
it('loads persisted view mode from vault config', () => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore(
|
||||
{ zoom: null, view_mode: 'editor-only', tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
|
||||
{ zoom: null, view_mode: 'editor-only', tag_colors: null, status_colors: null, property_display_modes: null },
|
||||
vi.fn(),
|
||||
)
|
||||
const { result } = renderHook(() => useViewMode())
|
||||
@@ -69,7 +69,7 @@ describe('useViewMode', () => {
|
||||
it('ignores invalid vault config values', () => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore(
|
||||
{ zoom: null, view_mode: 'garbage' as never, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
|
||||
{ zoom: null, view_mode: 'garbage' as never, tag_colors: null, status_colors: null, property_display_modes: null },
|
||||
vi.fn(),
|
||||
)
|
||||
const { result } = renderHook(() => useViewMode())
|
||||
|
||||
@@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react'
|
||||
import { useZoom } from './useZoom'
|
||||
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
|
||||
|
||||
const DEFAULT_VC = { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null } as const
|
||||
const DEFAULT_VC = { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null } as const
|
||||
|
||||
describe('useZoom', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -36,7 +36,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
|
||||
properties: { Priority: 'High', 'Due date': '2026-06-15' },
|
||||
},
|
||||
@@ -73,7 +73,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
|
||||
properties: { Priority: 'High', Rating: 5, Cadence: 'Weekly' },
|
||||
},
|
||||
@@ -104,7 +104,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['person/matteo-cellini'],
|
||||
properties: {},
|
||||
},
|
||||
@@ -135,7 +135,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
properties: {},
|
||||
},
|
||||
@@ -166,7 +166,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['responsibility/manage-sponsorships'],
|
||||
properties: {},
|
||||
},
|
||||
@@ -198,7 +198,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
|
||||
properties: { Priority: 'Low', 'Due date': '2026-03-01' },
|
||||
},
|
||||
@@ -230,7 +230,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
|
||||
properties: { Priority: 'Medium', Rating: 4 },
|
||||
},
|
||||
@@ -261,7 +261,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app'],
|
||||
properties: {},
|
||||
},
|
||||
@@ -291,7 +291,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: { Company: 'Acme Corp', Role: 'Engineering Lead' },
|
||||
},
|
||||
@@ -321,7 +321,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: { Company: 'TechStart', Role: 'Product Manager' },
|
||||
},
|
||||
@@ -351,7 +351,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -381,7 +381,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -412,7 +412,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
|
||||
properties: {},
|
||||
},
|
||||
@@ -443,7 +443,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -474,7 +474,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -505,7 +505,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
properties: {},
|
||||
},
|
||||
@@ -537,7 +537,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
|
||||
properties: {},
|
||||
},
|
||||
@@ -568,7 +568,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
properties: {},
|
||||
},
|
||||
@@ -597,7 +597,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 0,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -625,7 +625,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 1,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -653,7 +653,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 2,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -681,7 +681,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 3,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: false,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -709,7 +709,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 4,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -737,7 +737,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 5,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -765,7 +765,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 6,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -793,7 +793,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 7,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -821,7 +821,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 8,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -850,7 +850,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: 'orange',
|
||||
order: 9,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -878,7 +878,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: 'green',
|
||||
order: 10,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -909,7 +909,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: { Difficulty: 'Easy', 'Prep time': '30 min', Servings: 4 },
|
||||
},
|
||||
@@ -939,7 +939,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: { Author: 'Martin Kleppmann', Rating: 5, 'Year published': 2017 },
|
||||
},
|
||||
@@ -971,7 +971,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -1001,7 +1001,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -1032,7 +1032,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -1055,7 +1055,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
modifiedAt: now - 86400 * 120,
|
||||
@@ -1086,7 +1086,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
modifiedAt: now - 86400 * 90,
|
||||
@@ -1151,7 +1151,7 @@ function generateBulkEntries(count: number): VaultEntry[] {
|
||||
order: null,
|
||||
outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`),
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
properties: {},
|
||||
})
|
||||
}
|
||||
@@ -1184,7 +1184,7 @@ MOCK_ENTRIES.push(
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -1212,7 +1212,7 @@ MOCK_ENTRIES.push(
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
@@ -1240,7 +1240,7 @@ MOCK_ENTRIES.push(
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
|
||||
@@ -319,12 +319,22 @@ line-height-base: 1.6
|
||||
|
||||
# ${displayName}
|
||||
`
|
||||
const now = Date.now() / 1000
|
||||
MOCK_ENTRIES.push({
|
||||
path, filename: `${slug}.md`, title: displayName, isA: 'Theme',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: now, createdAt: now, fileSize: 512, snippet: `A custom ${displayName} theme.`,
|
||||
wordCount: 10, relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
})
|
||||
syncWindowContent()
|
||||
return path
|
||||
},
|
||||
ensure_vault_themes: (): null => null,
|
||||
restore_default_themes: (): string => 'Default themes restored',
|
||||
get_vault_config: (): VaultConfig => ({ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null }),
|
||||
get_vault_config: (): VaultConfig => ({ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null }),
|
||||
save_vault_config: (): null => null,
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,13 @@ globalThis.ResizeObserver = class {
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver
|
||||
|
||||
// Mock IntersectionObserver for jsdom (not implemented)
|
||||
globalThis.IntersectionObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof IntersectionObserver
|
||||
|
||||
// Mock @tauri-apps/plugin-opener for test environment
|
||||
vi.mock('@tauri-apps/plugin-opener', () => ({
|
||||
openUrl: vi.fn(),
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface VaultEntry {
|
||||
sort: string | null
|
||||
/** Default view mode for the note list of this Type: "all", "editor-list", or "editor-only". */
|
||||
view: string | null
|
||||
/** Whether this Type is visible in the sidebar. Defaults to true when absent. */
|
||||
visible: boolean | null
|
||||
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
|
||||
outgoingLinks: string[]
|
||||
/** Custom scalar frontmatter properties (non-relationship, non-structural). */
|
||||
@@ -148,7 +150,6 @@ export interface VaultConfig {
|
||||
tag_colors: Record<string, string> | null
|
||||
status_colors: Record<string, string> | null
|
||||
property_display_modes: Record<string, string> | null
|
||||
hidden_sections: string[] | null
|
||||
}
|
||||
|
||||
export interface PulseFile {
|
||||
|
||||
@@ -9,7 +9,6 @@ function makeConfig(overrides: Partial<VaultConfig> = {}): VaultConfig {
|
||||
tag_colors: null,
|
||||
status_colors: null,
|
||||
property_display_modes: null,
|
||||
hidden_sections: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -116,27 +115,13 @@ describe('migrateLocalStorageToVaultConfig', () => {
|
||||
expect(result.property_display_modes).toBeNull()
|
||||
})
|
||||
|
||||
// 8. Hidden sections migration
|
||||
it('migrates populated hidden sections array', () => {
|
||||
store['laputa-hidden-sections'] = JSON.stringify(['backlinks', 'properties'])
|
||||
const result = migrateLocalStorageToVaultConfig(makeConfig())
|
||||
expect(result.hidden_sections).toEqual(['backlinks', 'properties'])
|
||||
})
|
||||
|
||||
it('ignores empty hidden sections array', () => {
|
||||
store['laputa-hidden-sections'] = JSON.stringify([])
|
||||
const result = migrateLocalStorageToVaultConfig(makeConfig())
|
||||
expect(result.hidden_sections).toBeNull()
|
||||
})
|
||||
|
||||
// 9. Existing config values are NOT overwritten
|
||||
// 8. Existing config values are NOT overwritten
|
||||
it('does not overwrite existing config values with localStorage data', () => {
|
||||
store['laputa:zoom-level'] = '120'
|
||||
store['laputa-view-mode'] = 'all'
|
||||
store['laputa:tag-color-overrides'] = JSON.stringify({ x: '#fff' })
|
||||
store['laputa:status-color-overrides'] = JSON.stringify({ y: '#000' })
|
||||
store['laputa:display-mode-overrides'] = JSON.stringify({ z: 'compact' })
|
||||
store['laputa-hidden-sections'] = JSON.stringify(['nav'])
|
||||
|
||||
const existing = makeConfig({
|
||||
zoom: 0.9,
|
||||
@@ -144,7 +129,6 @@ describe('migrateLocalStorageToVaultConfig', () => {
|
||||
tag_colors: { existing: '#aaa' },
|
||||
status_colors: { existing: '#bbb' },
|
||||
property_display_modes: { existing: 'full' },
|
||||
hidden_sections: ['sidebar'],
|
||||
})
|
||||
|
||||
const result = migrateLocalStorageToVaultConfig(existing)
|
||||
@@ -153,7 +137,6 @@ describe('migrateLocalStorageToVaultConfig', () => {
|
||||
expect(result.tag_colors).toEqual({ existing: '#aaa' })
|
||||
expect(result.status_colors).toEqual({ existing: '#bbb' })
|
||||
expect(result.property_display_modes).toEqual({ existing: 'full' })
|
||||
expect(result.hidden_sections).toEqual(['sidebar'])
|
||||
})
|
||||
|
||||
// 10. loaded=null (no vault config file yet) — uses defaults then migrates
|
||||
|
||||
@@ -9,7 +9,6 @@ const LS_KEYS = {
|
||||
tagColors: 'laputa:tag-color-overrides',
|
||||
statusColors: 'laputa:status-color-overrides',
|
||||
propertyModes: 'laputa:display-mode-overrides',
|
||||
hiddenSections: 'laputa-hidden-sections',
|
||||
} as const
|
||||
|
||||
function readJson<T>(key: string): T | null {
|
||||
@@ -29,7 +28,7 @@ function readJson<T>(key: string): T | null {
|
||||
export function migrateLocalStorageToVaultConfig(loaded: VaultConfig | null): VaultConfig {
|
||||
const base: VaultConfig = loaded ?? {
|
||||
zoom: null, view_mode: null, tag_colors: null,
|
||||
status_colors: null, property_display_modes: null, hidden_sections: null,
|
||||
status_colors: null, property_display_modes: null,
|
||||
}
|
||||
|
||||
// Skip migration if already done
|
||||
@@ -80,12 +79,6 @@ export function migrateLocalStorageToVaultConfig(loaded: VaultConfig | null): Va
|
||||
if (modes && Object.keys(modes).length > 0) result.property_display_modes = modes
|
||||
}
|
||||
|
||||
// Hidden sections
|
||||
if (result.hidden_sections === null) {
|
||||
const sections = readJson<string[]>(LS_KEYS.hiddenSections)
|
||||
if (sections && sections.length > 0) result.hidden_sections = sections
|
||||
}
|
||||
|
||||
// Mark migration as done
|
||||
try {
|
||||
localStorage.setItem(MIGRATION_FLAG, '1')
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('statusStyles — color overrides', () => {
|
||||
beforeEach(() => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore(
|
||||
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
|
||||
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null },
|
||||
vi.fn(),
|
||||
)
|
||||
// Reset module-level cache by re-initializing with empty overrides
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('tagStyles — color overrides', () => {
|
||||
beforeEach(() => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore(
|
||||
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
|
||||
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null },
|
||||
vi.fn(),
|
||||
)
|
||||
// Reset module-level cache
|
||||
|
||||
@@ -22,3 +22,11 @@ export async function openExternalUrl(url: string): Promise<void> {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a local file path with the system default app (e.g. TextEdit for .json). */
|
||||
export async function openLocalFile(absolutePath: string): Promise<void> {
|
||||
if (isTauri()) {
|
||||
const { openPath } = await import('@tauri-apps/plugin-opener')
|
||||
await openPath(absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ type Listener = () => void
|
||||
|
||||
const DEFAULT_CONFIG: VaultConfig = {
|
||||
zoom: null, view_mode: null, tag_colors: null,
|
||||
status_colors: null, property_display_modes: null, hidden_sections: null,
|
||||
status_colors: null, property_display_modes: null,
|
||||
}
|
||||
|
||||
let config: VaultConfig = DEFAULT_CONFIG
|
||||
|
||||
71
tests/smoke/example.spec.ts
Normal file
71
tests/smoke/example.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
openCommandPalette,
|
||||
closeCommandPalette,
|
||||
findCommand,
|
||||
sendShortcut,
|
||||
verifyVisible,
|
||||
} from './helpers'
|
||||
|
||||
test.describe('Command Palette smoke tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('Cmd+K opens the command palette', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
await verifyVisible(page, 'input[placeholder="Type a command..."]')
|
||||
})
|
||||
|
||||
test('Escape closes the command palette', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
await closeCommandPalette(page)
|
||||
await expect(
|
||||
page.locator('input[placeholder="Type a command..."]'),
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('typing filters the command list', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
const found = await findCommand(page, 'settings')
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
|
||||
test('arrow keys navigate commands', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
const first = await page
|
||||
.locator('[data-selected="true"]')
|
||||
.first()
|
||||
.textContent()
|
||||
await page.keyboard.press('ArrowDown')
|
||||
const second = await page
|
||||
.locator('[data-selected="true"]')
|
||||
.first()
|
||||
.textContent()
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Keyboard shortcuts smoke tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('Cmd+P opens quick open palette', async ({ page }) => {
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
await expect(
|
||||
page.locator('input[placeholder="Search notes..."]'),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Escape closes command palette after Cmd+K', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(
|
||||
page.locator('input[placeholder="Type a command..."]'),
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
65
tests/smoke/helpers.ts
Normal file
65
tests/smoke/helpers.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { type Page, expect } from '@playwright/test'
|
||||
|
||||
const COMMAND_INPUT = 'input[placeholder="Type a command..."]'
|
||||
|
||||
export async function openCommandPalette(page: Page): Promise<void> {
|
||||
await page.locator('body').click()
|
||||
await page.keyboard.press('Control+k')
|
||||
await expect(page.locator(COMMAND_INPUT)).toBeVisible()
|
||||
}
|
||||
|
||||
export async function closeCommandPalette(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator(COMMAND_INPUT)).not.toBeVisible()
|
||||
}
|
||||
|
||||
export async function findCommand(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
await page.locator(COMMAND_INPUT).fill(name)
|
||||
const match = page.locator('[data-selected="true"]').first()
|
||||
try {
|
||||
await match.waitFor({ timeout: 2_000 })
|
||||
const text = await match.textContent()
|
||||
return text?.toLowerCase().includes(name.toLowerCase()) ?? false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCommand(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
await page.locator(COMMAND_INPUT).fill(name)
|
||||
const match = page.locator('[data-selected="true"]').first()
|
||||
await match.waitFor({ timeout: 2_000 })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
export async function verifyVisible(
|
||||
page: Page,
|
||||
selector: string,
|
||||
): Promise<void> {
|
||||
await expect(page.locator(selector).first()).toBeVisible()
|
||||
}
|
||||
|
||||
export async function verifyFocusable(
|
||||
page: Page,
|
||||
selector: string,
|
||||
): Promise<void> {
|
||||
const el = page.locator(selector).first()
|
||||
await expect(el).toBeVisible()
|
||||
await el.focus()
|
||||
await expect(el).toBeFocused()
|
||||
}
|
||||
|
||||
export async function sendShortcut(
|
||||
page: Page,
|
||||
key: string,
|
||||
modifiers: Array<'Meta' | 'Control' | 'Shift' | 'Alt'> = [],
|
||||
): Promise<void> {
|
||||
const combo = [...modifiers, key].join('+')
|
||||
await page.keyboard.press(combo)
|
||||
}
|
||||
36
tests/smoke/sidebar-duplicate-sections.spec.ts
Normal file
36
tests/smoke/sidebar-duplicate-sections.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Sidebar duplicate sections', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('no duplicate sidebar section headers', async ({ page }) => {
|
||||
// Verify that every section label in the sidebar is unique.
|
||||
// Before the fix, hyphenated folder names (e.g. monday-ideas/)
|
||||
// would produce a different isA than the Type file title,
|
||||
// resulting in two sidebar sections for the same type.
|
||||
|
||||
const sidebar = page.locator('.app__sidebar')
|
||||
|
||||
// Wait for at least one Expand/Collapse button (indicates sections rendered)
|
||||
await sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]').first().waitFor({ timeout: 5000 })
|
||||
|
||||
// Extract section labels from the Expand/Collapse aria-labels
|
||||
const buttons = sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]')
|
||||
const count = await buttons.count()
|
||||
const labels: string[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const ariaLabel = await buttons.nth(i).getAttribute('aria-label')
|
||||
// aria-label is like "Collapse Projects" or "Expand Projects"
|
||||
const label = ariaLabel?.replace(/^(Collapse|Expand)\s+/, '') ?? ''
|
||||
if (label) labels.push(label)
|
||||
}
|
||||
|
||||
// Every label should be unique — no duplicates
|
||||
const uniqueLabels = [...new Set(labels)]
|
||||
expect(labels).toEqual(uniqueLabels)
|
||||
expect(labels.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
39
tests/smoke/visible-type-property.spec.ts
Normal file
39
tests/smoke/visible-type-property.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Type visibility in sidebar', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('sections with visible:false Type entries are hidden from sidebar', async ({
|
||||
page,
|
||||
}) => {
|
||||
const sidebar = page.locator('.app__sidebar')
|
||||
|
||||
// Wait for sections to render
|
||||
await sidebar
|
||||
.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]')
|
||||
.first()
|
||||
.waitFor({ timeout: 5000 })
|
||||
|
||||
// Extract section labels from Expand/Collapse buttons
|
||||
const buttons = sidebar.locator(
|
||||
'button[aria-label*="Collapse"], button[aria-label*="Expand"]',
|
||||
)
|
||||
const count = await buttons.count()
|
||||
const labels: string[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const ariaLabel = await buttons.nth(i).getAttribute('aria-label')
|
||||
const label = ariaLabel?.replace(/^(Collapse|Expand)\s+/, '') ?? ''
|
||||
if (label) labels.push(label)
|
||||
}
|
||||
|
||||
// Verify that at least some sections render (sanity check)
|
||||
expect(labels.length).toBeGreaterThan(3)
|
||||
|
||||
// All visible section labels should be unique (regression check)
|
||||
const uniqueLabels = [...new Set(labels)]
|
||||
expect(labels).toEqual(uniqueLabels)
|
||||
})
|
||||
})
|
||||
@@ -19,11 +19,25 @@ interface VaultEntry {
|
||||
status: string | null
|
||||
owner: string | null
|
||||
cadence: string | null
|
||||
archived: boolean
|
||||
trashed: boolean
|
||||
trashedAt: number | null
|
||||
modifiedAt: number | null
|
||||
createdAt: number | null
|
||||
fileSize: number
|
||||
snippet: string
|
||||
wordCount: number
|
||||
relationships: Record<string, string[]>
|
||||
icon: string | null
|
||||
color: string | null
|
||||
order: number | null
|
||||
sidebarLabel: string | null
|
||||
template: string | null
|
||||
sort: string | null
|
||||
view: string | null
|
||||
visible: boolean | null
|
||||
outgoingLinks: string[]
|
||||
properties: Record<string, string | number | boolean | null>
|
||||
}
|
||||
|
||||
/** Extract all [[wiki-links]] from a string. */
|
||||
@@ -115,22 +129,48 @@ function parseMarkdownFile(filePath: string): VaultEntry | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Boolean field helper
|
||||
const getBool = (...keys: string[]): boolean | null => {
|
||||
for (const k of keys) {
|
||||
for (const fk of Object.keys(fm)) {
|
||||
if (fk.toLowerCase() === k.toLowerCase() && typeof fm[fk] === 'boolean') {
|
||||
return fm[fk]
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
filename,
|
||||
title,
|
||||
isA: getString('is_a', 'is a'),
|
||||
isA: getString('is_a', 'is a', 'type'),
|
||||
aliases,
|
||||
belongsTo,
|
||||
relatedTo,
|
||||
status: getString('status'),
|
||||
owner: getString('owner'),
|
||||
cadence: getString('cadence'),
|
||||
archived: getBool('archived') ?? false,
|
||||
trashed: getBool('trashed') ?? false,
|
||||
trashedAt: null,
|
||||
modifiedAt: stats.mtimeMs,
|
||||
createdAt,
|
||||
fileSize: stats.size,
|
||||
snippet,
|
||||
wordCount: bodyText.split(/\s+/).filter(Boolean).length,
|
||||
relationships,
|
||||
icon: getString('icon'),
|
||||
color: getString('color'),
|
||||
order: fm.order != null ? Number(fm.order) : null,
|
||||
sidebarLabel: getString('sidebar label', 'sidebar_label'),
|
||||
template: getString('template'),
|
||||
sort: getString('sort'),
|
||||
view: getString('view'),
|
||||
visible: getBool('visible'),
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user