Compare commits

...

22 Commits

Author SHA1 Message Date
Test
b9d94abae4 style: rustfmt vault_config.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:58:39 +01:00
Test
18b2aaedf6 fix: add missing visible field to buildNewEntry in useNoteActions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:57:00 +01:00
Test
1706300494 test: add Playwright smoke test for visible type property
Also updates vite.config.ts vault API parser to include all VaultEntry
fields (visible, icon, color, order, etc.) so the dev server returns
complete entries for sidebar filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:52:40 +01:00
Test
628ab76f09 feat: migrate hidden_sections from ui.config to visible property on Type notes
On startup, reads hidden_sections from config/ui.config.md, creates or
updates Type notes with visible: false, then re-saves config without
hidden_sections. Idempotent and safe to run multiple times.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:44:30 +01:00
Test
d3896ddf01 refactor: remove hidden_sections from VaultConfig and delete useSectionVisibility
Sidebar section visibility is now controlled entirely by the `visible`
property on Type notes. Removes all hidden_sections references from
Rust struct, TypeScript interface, config migration, mock data, and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:41:00 +01:00
Test
7289a60db3 feat: add handleToggleTypeVisibility to useEntryActions
Toggle visible property on Type note frontmatter: sets visible:false
to hide, deletes visible property to show (defaults to visible).
Wire up in App.tsx via onToggleTypeVisibility prop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:35:18 +01:00
Test
0cff626e48 feat: filter sidebar sections by Type entry visible property
Replace useSectionVisibility hook with direct filtering on
typeEntryMap[type]?.visible !== false. Add onToggleTypeVisibility
callback prop. Tests updated to verify visible:false hides sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:33:47 +01:00
Test
5a4c986fe3 feat: add visible field to VaultEntry for Type note sidebar visibility
Parse `visible` boolean from Type note frontmatter (defaults to None/true
when absent). Add to SKIP_KEYS to exclude from relationships and properties.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:30:18 +01:00
Test
540b1400e2 🐛 Auto-untrack .laputa-cache.json and .laputa/settings.json from git
These are machine-local files that should never be version controlled:
- .laputa-cache.json: contains absolute paths, changes on every machine
- .laputa/settings.json: per-machine UI settings

Fix: ensure_cache_excluded() now:
1. Adds both files to .git/info/exclude (git-level ignore, no .gitignore needed)
2. Runs `git rm --cached --ignore-unmatch` on vault open to un-track them
   if they were committed in older vaults

This is idempotent and self-healing — existing vaults fix themselves
automatically on next app launch without any manual steps.
2026-03-06 21:10:37 +01:00
Test
826cda852a Pulse: lazy pagination with IntersectionObserver infinite scroll
- Page size reduced from 30 → 20 commits per fetch
- Backend: add `skip` param to get_vault_pulse (git log --skip)
  → true pagination instead of re-fetching everything
- Frontend: IntersectionObserver sentinel at bottom of feed
  → auto-loads next page when user scrolls near end
- Append-only updates (no full re-render on load more)
- Add IntersectionObserver mock to test setup
2026-03-06 21:04:24 +01:00
Test
19583ea1f5 💅 Pulse: add right border, collapse commit files by default
- Add border-r border-[var(--sidebar-border)] to PulseView container
- CommitCard files default to collapsed (expanded: false) for cleaner initial state
- Update tests to reflect new collapsed-by-default behavior
2026-03-06 21:00:35 +01:00
Test
63eb4ff980 fix: address clippy lints in title_case_folder
Use char array pattern and function reference instead of closures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:11:57 +01:00
Test
bbb29857b8 test: add regression tests for hyphenated folder sidebar duplicates
- Sidebar unit test: entries with isA 'Monday Ideas' produce exactly
  one section header (not two)
- Playwright smoke test: verify no duplicate section labels in sidebar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:09:29 +01:00
Test
900ce7f66f fix: normalize hyphenated folder names in infer_type_from_folder()
Split on hyphens and underscores, capitalize each word, join with
spaces. E.g. monday-ideas → Monday Ideas, key-result → Key Result.
This prevents duplicate sidebar sections when folder name differs
from the Type file title.

Bump CACHE_VERSION to 5 to force rescan on existing caches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:59:51 +01:00
Test
eb55c5ec02 refactor: apply rustfmt formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:43:43 +01:00
Test
6f6e7d7cfe fix: vault cache misses files in new directories, breaking theme restore
Root cause: git_uncommitted_files() used `git status --porcelain` which
reports new untracked directories as `?? theme/` instead of listing
individual files inside. The .md filter skipped directory entries, so
files in newly-created directories (theme/default.md, etc.) were
invisible to the cache system.

Fix: additionally use `git ls-files --others --exclude-standard` which
lists individual untracked files, resolving directories to their
contents.

Also:
- Add type/theme.md definition (icon: palette) to restore and getting-started vault
- Seed theme/*.md vault notes in getting-started vault
- Fix mock create_vault_theme to add entries for dev mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:38:54 +01:00
Test
edcb306c7f fix: exclude .laputa-cache.json and settings.json from vault git tracking
These files are machine-specific and should never be committed or cause
conflicts when syncing vaults across devices.

Changes:
- init_repo() now writes .gitignore with .laputa-cache.json and
  .laputa/settings.json excluded before the first commit
- .DS_Store and common editor artifacts also excluded
- openConflictFileRef falls back to openLocalFile() for non-note files
  so 'Open in editor' works for .json conflict files (opens in system
  default app, e.g. TextEdit/VS Code)
- Removed stale git.rs (replaced by git/mod.rs from refactor)
- 2 new Rust tests for .gitignore creation behavior
2026-03-06 15:22:48 +01:00
Test
97be1d1ca3 fix: write .gitignore on vault init to exclude .DS_Store
macOS creates .DS_Store files in every folder which were being tracked
by git, causing constant conflicts when vaults are synced across machines.

init_repo() now writes a .gitignore before the first commit with:
- .DS_Store, .AppleDouble, .LSOverride
- ._ thumbnail files
- Common editor artifacts (.vscode/, .idea/, *.swp)

The file is only written if one doesn't already exist, so user-customized
.gitignore files are respected.

Two new Rust tests verify the behavior.
2026-03-06 15:19:25 +01:00
Test
ea29a81d79 refactor: split Rust backend into sub-modules — git, theme, github, frontmatter, commands
- git.rs (1907 lines) → 7 files: mod, history, status, commit, remote, conflict, pulse
- theme.rs (1075 lines) → 4 files: mod, defaults, seed, create
- github.rs (886 lines) → 4 files: mod, api, auth, clone
- frontmatter.rs (827 lines) → 3 files: mod, yaml, ops
- lib.rs (772 lines) → lib.rs (160) + commands.rs (650)

484 tests pass, clippy clean, rustfmt clean, coverage 85.42%
2026-03-06 13:16:32 +01:00
Test
5b1fda2279 chore: exclude tools/, scripts/ from CodeScene analysis
tools/qmd/ contains vendored qmd source code (not our code) that was
dragging the overall code health score down to 6.53.
scripts/ contains one-shot utility scripts, not production code.

Excluding both from CodeScene so the health metric reflects only
the actual app codebase (src/, src-tauri/src/).
2026-03-06 11:32:22 +01:00
Test
1cd596061a fix: latest.json must point to .tar.gz not .dmg for Tauri in-app updater
The Tauri updater plugin requires the .app.tar.gz artifact as the update
URL — not the .dmg installer. The DMG is for fresh installs only.
This was causing 'Install Update' to silently fail on all macOS builds
since the auto-updater infrastructure was set up.

Change ARM_DMG → ARM_TARBALL, pointing to the .app.tar.gz artifact.
2026-03-06 10:54:04 +01:00
Test
efb233b18f feat: add Playwright smoke test infrastructure for task-scoped QA
Adds headless Chromium smoke tests that run before push, catching
UI/UX bugs before Brian QA. Includes shared helpers for command
palette and keyboard shortcut testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:39:20 +01:00
77 changed files with 7237 additions and 5670 deletions

View File

@@ -1 +1 @@
45402
33549

9
.codescenerc Normal file
View File

@@ -0,0 +1,9 @@
{
"exclude": [
"tools/",
"scripts/",
"src-tauri/gen/",
"coverage/",
"dist/"
]
}

View File

@@ -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}"
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
{
"theme": null
}

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

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

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
---
type: config
zoom: 1.3
view_mode: all
---

View File

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

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

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

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

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

View File

@@ -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"
},

View File

@@ -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
View 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(&note, "---\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(&note).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(&note, "---\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(&note).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());
}
}

View File

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

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

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

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

File diff suppressed because it is too large Load Diff

View 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'"
);
}
}

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) }, [])

View File

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

View File

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

View File

@@ -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: {},
}
}

View File

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

View File

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

View File

@@ -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(() => {

View File

@@ -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: {},
},

View File

@@ -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,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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