Compare commits
105 Commits
v0.1.0
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89d0e39ad2 | ||
|
|
e3977a6042 | ||
|
|
4cdc534c01 | ||
|
|
1a93acdce7 | ||
|
|
9c8f7907b8 | ||
|
|
8d9862ffef | ||
|
|
a5e4efcf86 | ||
|
|
e4bab72952 | ||
|
|
f08b807a0c | ||
|
|
b521736102 | ||
|
|
4d13628d0b | ||
|
|
2b6000f5d4 | ||
|
|
894cb45779 | ||
|
|
2a1b17f8c6 | ||
|
|
47ca3cb13c | ||
|
|
5fb059d2e9 | ||
|
|
bd7feb94e7 | ||
|
|
de7b624902 | ||
|
|
7e1057482e | ||
|
|
7aa4bf7efe | ||
|
|
6e99bf7d03 | ||
|
|
669d473a64 | ||
|
|
ba1404b808 | ||
|
|
98314c5106 | ||
|
|
bac5a911c1 | ||
|
|
67155db9ab | ||
|
|
b42a4d3608 | ||
|
|
e29be460c3 | ||
|
|
bcfbc8e00e | ||
|
|
95bcf3b25a | ||
|
|
3fff794d9b | ||
|
|
de6023547f | ||
|
|
0effc563dc | ||
|
|
37ac70f720 | ||
|
|
222f697873 | ||
|
|
681554af58 | ||
|
|
87bce9d4cc | ||
|
|
248774ed13 | ||
|
|
6363e84402 | ||
|
|
8106eb86a8 | ||
|
|
7feae25d45 | ||
|
|
cc08055e0a | ||
|
|
c789b49bef | ||
|
|
bccc0ceed4 | ||
|
|
b924ec9b95 | ||
|
|
c56a8c0e7f | ||
|
|
c5bcbecf1f | ||
|
|
5ef4f2d642 | ||
|
|
f52ce5c618 | ||
|
|
d83fef67f0 | ||
|
|
8a91d25395 | ||
|
|
77f76a33a4 | ||
|
|
d8aa615ea3 | ||
|
|
82dafe9334 | ||
|
|
aff56af6aa | ||
|
|
4e2fa0d374 | ||
|
|
465a80d3e1 | ||
|
|
ce12e3cd6f | ||
|
|
e67eb5e85e | ||
|
|
c11184b24a | ||
|
|
ee42731ecc | ||
|
|
3c57b0ff1a | ||
|
|
bd0576c593 | ||
|
|
57f0378bab | ||
|
|
1ff342ae60 | ||
|
|
da55318979 | ||
|
|
384cffec48 | ||
|
|
c0ce5064d8 | ||
|
|
e9c869075a | ||
|
|
f881c13b62 | ||
|
|
92c7b003d9 | ||
|
|
cafdc84260 | ||
|
|
d87c80ece4 | ||
|
|
a67e0bd221 | ||
|
|
ed8a51aa8f | ||
|
|
fb763f5338 | ||
|
|
a18a19166b | ||
|
|
1efdbfb978 | ||
|
|
233497cb5c | ||
|
|
6ff3a1929a | ||
|
|
42b463e07a | ||
|
|
a6b92d5248 | ||
|
|
8c574882e5 | ||
|
|
bb0a5c5c98 | ||
|
|
cd1246d43c | ||
|
|
92c8dbd1bf | ||
|
|
4672fcb5c0 | ||
|
|
d962cd9eae | ||
|
|
88a8035c45 | ||
|
|
035bd7f630 | ||
|
|
9ce890576b | ||
|
|
f569750666 | ||
|
|
9cfa8a7ca2 | ||
|
|
918b204d3d | ||
|
|
177e593e90 | ||
|
|
959e4975e3 | ||
|
|
a2f1476b98 | ||
|
|
9d5c90f5c0 | ||
|
|
e91a7ae7f6 | ||
|
|
47e38ba6b0 | ||
|
|
cbb88b34b8 | ||
|
|
52b1693f43 | ||
|
|
3d784ce740 | ||
|
|
480d124a0e | ||
|
|
b15a4d143c |
1
(HOME)/.tauri/laputa-v2.key
Normal file
@@ -0,0 +1 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5V3BxWUNBZU1LZWxOK3ZEZTZkdGhzM2l6cnpVUmIvUEtTTWgzLzNEU1VoZ0FBQkFBQUFBQUFBQUFBQUlBQUFBQUFpN2xxclpGK3YzRERub1EvZFdsdVdORktuOHZYVlB0S2U2QkhNdlMreElkRVdabTh6UllNYzNFb2VWYTVCayszNUpyOC94Z0dJS2pVQ3NSKzdKNEszZHpDZll0aGdtV1J6bWFXODc4VWJpOVFYdUhEai9tNHQ2U3ZRbVd1NHBkR01YS2ZrUDlPRVU9Cg==
|
||||
1
(HOME)/.tauri/laputa-v2.key.pub
Normal file
@@ -0,0 +1 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVCQTVDRkIzNkFGRTYwQjUKUldTMVlQNXFzOCtsVzROZmJLR0JFVGw1a1UzKzViY3dUcWFoaUttRFhhVk8rVUhrc29QL1FPeXUK
|
||||
36
.claude-done
@@ -1,36 +0,0 @@
|
||||
task: refactor-editor-notelist-sidebar
|
||||
ui-change: no
|
||||
|
||||
## Summary
|
||||
|
||||
Refactored three CodeScene hotspot files to improve code health scores:
|
||||
|
||||
| File | Before | After | Approach |
|
||||
|------|--------|-------|----------|
|
||||
| Editor.tsx | 7.82 (cc=35) | 10.0 | Decomposed into EditorContent, EditorRightPanel, SingleEditorView, editorSchema, useDiffMode, useEditorFocus, suggestionEnrichment |
|
||||
| NoteList.tsx | 9.04 (cc=13) | 9.38 | Extracted createNoteStatusResolver, toggleSetMember standalone functions |
|
||||
| Sidebar.tsx | 9.09 (cc=10) | 10.0 | Extracted applyCustomization standalone function |
|
||||
|
||||
## New files
|
||||
|
||||
- `src/components/EditorContent.tsx` — breadcrumb bar + editor/diff view switching
|
||||
- `src/components/EditorRightPanel.tsx` — AI chat / inspector panel toggle
|
||||
- `src/components/SingleEditorView.tsx` — BlockNote editor with suggestion menus
|
||||
- `src/components/editorSchema.tsx` — WikiLink inline content spec + BlockNote schema
|
||||
- `src/hooks/useDiffMode.ts` — diff mode state management
|
||||
- `src/hooks/useEditorFocus.ts` — editor focus on custom event
|
||||
- `src/utils/suggestionEnrichment.ts` — shared suggestion item enrichment pipeline
|
||||
|
||||
## New tests (20 tests)
|
||||
|
||||
- `src/hooks/useDiffMode.test.ts` — 10 tests
|
||||
- `src/hooks/useEditorFocus.test.ts` — 3 tests
|
||||
- `src/utils/suggestionEnrichment.test.ts` — 7 tests
|
||||
|
||||
## Validation
|
||||
|
||||
- 976 tests pass (956 existing + 20 new)
|
||||
- 80% frontend coverage (≥70% required)
|
||||
- 270 Rust tests pass
|
||||
- CodeScene quality gates: passed
|
||||
- Zero behavior change — pure refactor
|
||||
1
.claude-pid
Normal file
@@ -0,0 +1 @@
|
||||
81859
|
||||
2
.github/workflows/ci.yml
vendored
@@ -72,8 +72,10 @@ jobs:
|
||||
run: |
|
||||
cargo llvm-cov \
|
||||
--manifest-path src-tauri/Cargo.toml \
|
||||
--ignore-filename-regex 'lib\.rs|main\.rs|menu\.rs' \
|
||||
--fail-under-lines 85
|
||||
# cargo-llvm-cov exits non-zero if line coverage drops below 85%
|
||||
# lib.rs/main.rs/menu.rs are Tauri boilerplate -- not meaningfully unit-testable.
|
||||
|
||||
# ── 3. Code Health (CodeScene — Hotspot Code Health gate) ────────────
|
||||
# The webhook integration handles per-PR delta analysis (posts review
|
||||
|
||||
12
.github/workflows/release.yml
vendored
@@ -80,10 +80,16 @@ jobs:
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Build Tauri app (with signing)
|
||||
- name: Build Tauri app (with signing + notarization)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ""
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
pnpm tauri build --target ${{ matrix.target }}
|
||||
|
||||
@@ -196,6 +202,8 @@ jobs:
|
||||
run: |
|
||||
mkdir -p _site
|
||||
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
# Copy latest.json to GitHub Pages for auto-updater endpoint
|
||||
gh release download --repo ${{ github.repository }} --pattern "latest.json" --output _site/latest.json || true
|
||||
cat > _site/index.html << 'HTMLEOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
1
.gitignore
vendored
@@ -41,3 +41,4 @@ final_selection.py
|
||||
# Claude Code task signals
|
||||
.claude-done
|
||||
.claude-blocked
|
||||
src-tauri/target
|
||||
|
||||
@@ -79,11 +79,15 @@ if [ "$RUST_CHANGED" = true ]; then
|
||||
else
|
||||
echo "🦀 [3/4] 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
|
||||
# shellcheck disable=SC2086
|
||||
cargo llvm-cov \
|
||||
--manifest-path src-tauri/Cargo.toml \
|
||||
$LLVM_COV_FLAGS \
|
||||
--fail-under-lines 85
|
||||
--ignore-filename-regex "search\.rs|lib\.rs" \
|
||||
--fail-under-lines 85 \
|
||||
-- --test-threads=1
|
||||
echo " ✅ Rust coverage OK"
|
||||
else
|
||||
echo "⏭️ [3/4] Rust coverage — skipped (no src-tauri/ changes)"
|
||||
|
||||
15
CLAUDE.md
@@ -17,6 +17,10 @@ pre_commit_code_health_safeguard # CodeScene ≥9.2 — if it fails, fix stru
|
||||
|
||||
## ⛔ BEFORE FIRING laputa-task-done — QA on real vault
|
||||
|
||||
> **⚠️ TAURI APP ONLY — never test in browser (`pnpm dev` / localhost)**
|
||||
> The browser dev server has a fake HTTP server (`/api/*` routes, mock handlers) that doesn't exist in the real Tauri app.
|
||||
> If it works in the browser but crashes in Tauri, it's broken. Always test in `pnpm tauri dev`.
|
||||
|
||||
1. Acquire lockfile: `echo $$ > /tmp/laputa-qa.lock && trap "rm -f /tmp/laputa-qa.lock" EXIT`
|
||||
2. Kill other instances: `pkill -x laputa 2>/dev/null || true; sleep 1`
|
||||
3. Start app: `pnpm tauri dev` from worktree
|
||||
@@ -114,6 +118,17 @@ bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/click.sh 400 300 # logical coords
|
||||
```
|
||||
|
||||
## Keyboard-First Principle (mandatory for every new feature)
|
||||
|
||||
Every feature must be reachable via keyboard. This is both a UX requirement and a QA requirement — Brian tests the native app using keyboard only (osascript key events, no mouse).
|
||||
|
||||
**Before marking any task done:**
|
||||
- Can the feature be triggered/used without touching the mouse?
|
||||
- If it requires clicking a button, add a command palette entry or keyboard shortcut
|
||||
- Document the shortcut in the command palette or menu bar
|
||||
|
||||
**If you add UI that is only reachable by mouse**, you must also add a keyboard path (command palette entry, shortcut, or Tab-navigable focus). No exceptions.
|
||||
|
||||
## Push Workflow (IMPORTANT — changed Feb 27, 2026)
|
||||
|
||||
**Push directly to main** — no PRs, no branches, no CI queue.
|
||||
|
||||
53
VISION.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Laputa — Vision
|
||||
|
||||
Laputa is a personal knowledge base where humans and AI agents collaborate as equals.
|
||||
|
||||
---
|
||||
|
||||
## Core principles
|
||||
|
||||
### 1. The vault is the source of truth
|
||||
Everything lives in the vault as plain text files. Notes, relations, configuration, instructions — all Markdown with YAML frontmatter. No proprietary database, no hidden state. If you can open a terminal, you can read your vault. If you can write Markdown, you can modify it.
|
||||
|
||||
### 2. Vault-native configuration
|
||||
Laputa configures itself through files inside the vault — the same files you write and read every day. There is no separate "settings app" or admin panel. If you want to change a theme, you edit a note. If you want to give instructions to an AI agent, you write a note. If you want to define a template, you create a note.
|
||||
|
||||
This applies to:
|
||||
- **`_themes/`** — themes as notes with a YAML block in the body. Edit `_themes/dark.md`, see the colors change in real time.
|
||||
- **`AGENTS.md`** — instructions for AI agents. Write what you want them to know about your vault in plain language. They read it before acting.
|
||||
- **`_templates/`** — note templates per type. Create `_templates/event.md` and every new event starts from that structure.
|
||||
- **`_procedures/`** — recurring tasks as notes with a `schedule` frontmatter field.
|
||||
|
||||
The principle: **if it can be expressed in frontmatter + Markdown, it doesn't need a UI**.
|
||||
|
||||
### 3. Structure through types, not folders
|
||||
Notes have a `type` field. Types determine folders, icons, and colors — but the structure is defined by the data, not the filesystem hierarchy. You can query "all events in February" without knowing anything about folder layout.
|
||||
|
||||
Relations between notes are expressed as frontmatter arrays: `people: [Marco, Sara]`. A wikilink `[[Marco]]` in the body navigates to the person note. The graph emerges from the data, not from a separate graph database.
|
||||
|
||||
### 4. The file is the interface
|
||||
You can use Laputa's UI, or you can open a terminal. Or a text editor. Or Claude Code. They all operate on the same files. There is no difference between "the app" and "the vault" — the vault is the app.
|
||||
|
||||
This is why Laputa has an MCP server: external agents get the same tools the in-app AI panel uses. The interface is a convenience, not a requirement.
|
||||
|
||||
### 5. Humans and AI as collaborators
|
||||
Pulse — the activity feed — shows the history of the vault without distinguishing between human commits and agent commits. That's intentional. Laputa is designed to be a space where you and your AI agents work together, each contributing to the same knowledge base.
|
||||
|
||||
The AI doesn't have a separate workspace. It works in yours.
|
||||
|
||||
---
|
||||
|
||||
## What Laputa is not
|
||||
|
||||
- Not a todo app (though you can use it as one)
|
||||
- Not a note-taking app that syncs to the cloud (the vault is yours, sync however you want — git, iCloud, rsync)
|
||||
- Not a replacement for a terminal (power users will use both)
|
||||
- Not trying to abstract away git (git is a feature, not an implementation detail)
|
||||
|
||||
---
|
||||
|
||||
## The long game
|
||||
|
||||
A vault that grows with you for years. Events, people, projects, thoughts — all interconnected, all version-controlled, all accessible to any tool that can read a file.
|
||||
|
||||
Ten years from now, your vault should still be readable. Plain text is forever.
|
||||
340
design/ai-agent-panel-ui.pen
Normal file
@@ -0,0 +1,340 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_panel_closed",
|
||||
"name": "AI Agent Panel — Closed (trigger button in toolbar)",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "ai_closed_editor",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_closed_toolbar",
|
||||
"name": "Right Toolbar",
|
||||
"layout": "vertical",
|
||||
"width": 36,
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"padding": [8, 4],
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_trigger_btn",
|
||||
"name": "AI Trigger Button",
|
||||
"width": 28,
|
||||
"height": 28,
|
||||
"cornerRadius": 6,
|
||||
"fill": "$--muted",
|
||||
"layout": "horizontal",
|
||||
"padding": [6, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ai_trigger_icon",
|
||||
"content": "✦",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontSize": 14
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_panel_open_idle",
|
||||
"name": "AI Agent Panel — Open, Idle (empty state)",
|
||||
"x": 1320,
|
||||
"y": 0,
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "ai_open_editor",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_panel_sidebar",
|
||||
"name": "AI Panel Sidebar",
|
||||
"layout": "vertical",
|
||||
"width": 320,
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_panel_header",
|
||||
"name": "Panel Header",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 45,
|
||||
"padding": [0, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ai_header_label",
|
||||
"content": "AI",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600",
|
||||
"fill": "$--muted-foreground",
|
||||
"width": "fill_container"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ai_close_icon",
|
||||
"content": "✕",
|
||||
"fontSize": 12,
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_empty_state",
|
||||
"name": "Empty State",
|
||||
"layout": "vertical",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"padding": [24, 24],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ai_empty_hint",
|
||||
"content": "Ask the AI agent to work on your vault…",
|
||||
"fontSize": 13,
|
||||
"fill": "$--muted-foreground",
|
||||
"textAlign": "center",
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_input_row",
|
||||
"name": "Input Row",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "ai_input_field",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"cornerRadius": 6,
|
||||
"fill": "$--muted"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_send_btn",
|
||||
"name": "Send Button",
|
||||
"width": 28,
|
||||
"height": 28,
|
||||
"cornerRadius": 6,
|
||||
"fill": "$--primary",
|
||||
"layout": "horizontal",
|
||||
"padding": [6, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ai_send_icon",
|
||||
"content": "→",
|
||||
"fontSize": 12,
|
||||
"fill": "$--primary-foreground"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_panel_active",
|
||||
"name": "AI Agent Panel — Active (streaming, action cards visible)",
|
||||
"x": 2640,
|
||||
"y": 0,
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "ai_active_editor",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_active_panel",
|
||||
"name": "AI Panel — With Messages",
|
||||
"layout": "vertical",
|
||||
"width": 320,
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_active_header",
|
||||
"name": "Panel Header",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 45,
|
||||
"padding": [0, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "ai_active_label",
|
||||
"content": "AI",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600",
|
||||
"fill": "$--muted-foreground",
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_message_thread",
|
||||
"name": "Message Thread",
|
||||
"layout": "vertical",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"padding": [12, 12],
|
||||
"gap": 16,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_user_msg",
|
||||
"name": "User Message",
|
||||
"layout": "vertical",
|
||||
"width": "fill_container",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "You",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "600",
|
||||
"fill": "$--muted-foreground"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Crea una nota evento per la riunione con Marco domani",
|
||||
"fontSize": 13,
|
||||
"fill": "$--foreground",
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_agent_msg",
|
||||
"name": "Agent Message + Actions",
|
||||
"layout": "vertical",
|
||||
"width": "fill_container",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_action_done",
|
||||
"name": "Action Card — Done",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"padding": [0, 8],
|
||||
"gap": 6,
|
||||
"cornerRadius": 4,
|
||||
"fill": "$--muted",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "✓",
|
||||
"fontSize": 12,
|
||||
"fill": "$--primary"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Loaded vault context",
|
||||
"fontSize": 12,
|
||||
"fill": "$--foreground",
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ai_action_progress",
|
||||
"name": "Action Card — In Progress",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"padding": [0, 8],
|
||||
"gap": 6,
|
||||
"cornerRadius": 4,
|
||||
"fill": "$--muted",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "◌",
|
||||
"fontSize": 12,
|
||||
"fill": "$--primary"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Creating: 2026-03-01-meeting-marco.md",
|
||||
"fontSize": 12,
|
||||
"fill": "$--foreground",
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Ho creato la nota evento e linkato Marco come partecipante. La trovi già aperta in un nuovo tab.",
|
||||
"fontSize": 13,
|
||||
"fill": "$--foreground",
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
design/ai-agent-wiring.pen
Normal file
96
design/build-number-status-bar.pen
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "build_number_status_bar",
|
||||
"name": "Build Number — Status Bar",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1200,
|
||||
"height": 36,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
0,
|
||||
16
|
||||
],
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "status_vault",
|
||||
"content": "vault-name",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "status_sep1",
|
||||
"content": "|",
|
||||
"fill": "$--border",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "status_build",
|
||||
"content": "b223",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "status_note",
|
||||
"content": "← dynamic build number from package.json, replacing hardcoded v0.4.2",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "italic"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "build_number_fallback",
|
||||
"name": "Build Number — Fallback (no build info)",
|
||||
"x": 0,
|
||||
"y": 60,
|
||||
"width": 400,
|
||||
"height": 36,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
0,
|
||||
16
|
||||
],
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fallback_build",
|
||||
"content": "b?",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fallback_note",
|
||||
"content": "← shows b? when build number unavailable",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "italic"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
387
design/command-palette-type-aware.pen
Normal file
@@ -0,0 +1,387 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_ev",
|
||||
"name": "Command Palette — type \"new ev\" autocomplete",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 620,
|
||||
"height": "fit_content(500)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 24,
|
||||
"padding": [32, 32],
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_title",
|
||||
"content": "Command Palette — \"new ev\" autocomplete",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_dialog",
|
||||
"name": "Palette Dialog",
|
||||
"width": 520,
|
||||
"height": "fit_content(440)",
|
||||
"fill": "$--popover",
|
||||
"layout": "vertical",
|
||||
"cornerRadius": [12, 12, 12, 12],
|
||||
"stroke": "$--border",
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_input_row",
|
||||
"width": "fill_container",
|
||||
"height": 48,
|
||||
"padding": [0, 16],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"stroke": "$--border",
|
||||
"strokeSides": ["bottom"],
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_query",
|
||||
"content": "new ev",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_results",
|
||||
"name": "Results",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"padding": [4, 0],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_group_label",
|
||||
"content": "NOTE",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 1,
|
||||
"padding": [8, 16, 4, 16]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_row1",
|
||||
"name": "New Event (selected)",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"padding": [0, 12],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"horizontalAlign": "spaceBetween",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"fill": "$--accent",
|
||||
"margin": [0, 4],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_row1_label",
|
||||
"content": "New Event",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_row2",
|
||||
"name": "New Event Log",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"padding": [0, 12],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"horizontalAlign": "spaceBetween",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"margin": [0, 4],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_row2_label",
|
||||
"content": "New Event Log",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_row3",
|
||||
"name": "New Experiment",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"padding": [0, 12],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"horizontalAlign": "spaceBetween",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"margin": [0, 4],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_row3_label",
|
||||
"content": "New Experiment",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_new_footer",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"padding": [0, 16],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"gap": 16,
|
||||
"stroke": "$--border",
|
||||
"strokeSides": ["top"],
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_hint1",
|
||||
"content": "↑↓ navigate",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_hint2",
|
||||
"content": "↵ select",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_new_hint3",
|
||||
"content": "esc close",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_pe",
|
||||
"name": "Command Palette — type \"list pe\" autocomplete",
|
||||
"x": 720,
|
||||
"y": 0,
|
||||
"width": 620,
|
||||
"height": "fit_content(500)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 24,
|
||||
"padding": [32, 32],
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_title",
|
||||
"content": "Command Palette — \"list pe\" autocomplete",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_dialog",
|
||||
"name": "Palette Dialog",
|
||||
"width": 520,
|
||||
"height": "fit_content(440)",
|
||||
"fill": "$--popover",
|
||||
"layout": "vertical",
|
||||
"cornerRadius": [12, 12, 12, 12],
|
||||
"stroke": "$--border",
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_input_row",
|
||||
"width": "fill_container",
|
||||
"height": 48,
|
||||
"padding": [0, 16],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"stroke": "$--border",
|
||||
"strokeSides": ["bottom"],
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_query",
|
||||
"content": "list pe",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_results",
|
||||
"name": "Results",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"padding": [4, 0],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_group_label",
|
||||
"content": "NAVIGATION",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 1,
|
||||
"padding": [8, 16, 4, 16]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_row1",
|
||||
"name": "List People (selected)",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"padding": [0, 12],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"horizontalAlign": "spaceBetween",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"fill": "$--accent",
|
||||
"margin": [0, 4],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_row1_label",
|
||||
"content": "List People",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_row2",
|
||||
"name": "List Procedures",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"padding": [0, 12],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"horizontalAlign": "spaceBetween",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"margin": [0, 4],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_row2_label",
|
||||
"content": "List Procedures",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_row3",
|
||||
"name": "List Projects",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"padding": [0, 12],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"horizontalAlign": "spaceBetween",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"margin": [0, 4],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_row3_label",
|
||||
"content": "List Projects",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cp_list_footer",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"padding": [0, 16],
|
||||
"layout": "horizontal",
|
||||
"verticalAlign": "center",
|
||||
"gap": 16,
|
||||
"stroke": "$--border",
|
||||
"strokeSides": ["top"],
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_hint1",
|
||||
"content": "↑↓ navigate",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_hint2",
|
||||
"content": "↵ select",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cp_list_hint3",
|
||||
"content": "esc close",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
78
design/drag-drop-images.pen
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "drag_drop_idle",
|
||||
"name": "Drag & Drop Images — Idle (no drag)",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 900,
|
||||
"height": 600,
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "drag_idle_editor",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"cornerRadius": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "drag_drop_active",
|
||||
"name": "Drag & Drop Images — Drag Over (overlay shown)",
|
||||
"x": 940,
|
||||
"y": 0,
|
||||
"width": 900,
|
||||
"height": 600,
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "drag_active_container",
|
||||
"name": "Editor with Drag Overlay",
|
||||
"layout": "vertical",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"children": [
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "drag_active_editor_bg",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "$--background"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "drag_overlay",
|
||||
"name": "Drop Overlay",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"layout": "vertical",
|
||||
"fill": "rgba(0,0,0,0.4)",
|
||||
"cornerRadius": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "drag_overlay_label",
|
||||
"content": "Drop image to insert",
|
||||
"fontSize": 18,
|
||||
"fontWeight": "600",
|
||||
"fill": "#ffffff",
|
||||
"textAlign": "center"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
56
design/fix-property-dropdown-narrow.pen
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "fpdn_before",
|
||||
"name": "Property Dropdown Narrow — Before (clipped)",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 280,
|
||||
"height": 200,
|
||||
"fill": "#1a1a1a",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": 12,
|
||||
"theme": "dark",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fpdn_before_label",
|
||||
"content": "Bug: StatusDropdown clipped by overflow:hidden container at 200-280px panel width",
|
||||
"fill": "#ff4444",
|
||||
"fontFamily": "SF Pro Text",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "regular",
|
||||
"letterSpacing": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "fpdn_after",
|
||||
"name": "Property Dropdown Narrow — After (portal, no clip)",
|
||||
"x": 320,
|
||||
"y": 0,
|
||||
"width": 280,
|
||||
"height": 200,
|
||||
"fill": "#1a1a1a",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": 12,
|
||||
"theme": "dark",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fpdn_after_label",
|
||||
"content": "Fix: StatusDropdown + DisplayModeSelector use createPortal with fixed positioning — renders above overflow:hidden, visible at any panel width",
|
||||
"fill": "#44bb44",
|
||||
"fontFamily": "SF Pro Text",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "regular",
|
||||
"letterSpacing": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
381
design/getting-started-vault.pen
Normal file
@@ -0,0 +1,381 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "welcome_screen",
|
||||
"name": "Getting Started — Welcome Screen",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"fill": "#F7F6F3",
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 0,
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "welcome_card",
|
||||
"name": "Welcome Card",
|
||||
"width": 520,
|
||||
"height": "fit_content",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": [12, 12, 12, 12],
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"gap": 24,
|
||||
"padding": [48, 48, 48, 48],
|
||||
"stroke": "#E9E9E7",
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "welcome_icon_wrap",
|
||||
"name": "Icon",
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"fill": "#EBF4FF",
|
||||
"cornerRadius": [16, 16, 16, 16],
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "welcome_icon_text",
|
||||
"content": "✦",
|
||||
"fontSize": 28,
|
||||
"fill": "#2383E2",
|
||||
"fontFamily": "Inter"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "welcome_text_group",
|
||||
"name": "Text Group",
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"gap": 8,
|
||||
"width": "fill_container",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "welcome_title",
|
||||
"content": "Welcome to Laputa",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 28,
|
||||
"fontWeight": "700",
|
||||
"letterSpacing": -0.5,
|
||||
"textAlign": "center"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "welcome_subtitle",
|
||||
"content": "Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.",
|
||||
"fill": "#787774",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"lineHeight": 1.6,
|
||||
"textAlign": "center"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "welcome_divider",
|
||||
"width": "fill_container",
|
||||
"height": 1,
|
||||
"fill": "#E9E9E7"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "welcome_actions",
|
||||
"name": "Actions",
|
||||
"layout": "vertical",
|
||||
"gap": 12,
|
||||
"width": "fill_container",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "btn_create_vault",
|
||||
"name": "Create Getting Started Vault Button",
|
||||
"width": "fill_container",
|
||||
"height": 44,
|
||||
"fill": "#2383E2",
|
||||
"cornerRadius": [8, 8, 8, 8],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_create_icon",
|
||||
"content": "+",
|
||||
"fill": "#FFFFFF",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_create_label",
|
||||
"content": "Create Getting Started vault",
|
||||
"fill": "#FFFFFF",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "btn_open_folder",
|
||||
"name": "Open Existing Folder Button",
|
||||
"width": "fill_container",
|
||||
"height": 44,
|
||||
"fill": "#FFFFFF",
|
||||
"stroke": "#E9E9E7",
|
||||
"strokeThickness": 1,
|
||||
"cornerRadius": [8, 8, 8, 8],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_open_icon",
|
||||
"content": "📂",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_open_label",
|
||||
"content": "Open an existing folder",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "welcome_hint",
|
||||
"content": "The Getting Started vault will be created in ~/Documents/Laputa",
|
||||
"fill": "#787774",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"textAlign": "center"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_missing_screen",
|
||||
"name": "Getting Started — Vault Missing Error",
|
||||
"x": 1540,
|
||||
"y": 0,
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"fill": "#F7F6F3",
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 0,
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "missing_card",
|
||||
"name": "Missing Vault Card",
|
||||
"width": 520,
|
||||
"height": "fit_content",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": [12, 12, 12, 12],
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"gap": 24,
|
||||
"padding": [48, 48, 48, 48],
|
||||
"stroke": "#E9E9E7",
|
||||
"strokeThickness": 1,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "missing_icon_wrap",
|
||||
"name": "Warning Icon",
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"fill": "#FFF3E0",
|
||||
"cornerRadius": [16, 16, 16, 16],
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "missing_icon_text",
|
||||
"content": "⚠",
|
||||
"fontSize": 28,
|
||||
"fill": "#E8890C",
|
||||
"fontFamily": "Inter"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "missing_text_group",
|
||||
"name": "Text Group",
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"gap": 8,
|
||||
"width": "fill_container",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "missing_title",
|
||||
"content": "Vault not found",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 28,
|
||||
"fontWeight": "700",
|
||||
"letterSpacing": -0.5,
|
||||
"textAlign": "center"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "missing_subtitle",
|
||||
"content": "The vault folder could not be found on disk.\nIt may have been moved or deleted.",
|
||||
"fill": "#787774",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"lineHeight": 1.6,
|
||||
"textAlign": "center"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "missing_path_badge",
|
||||
"name": "Path Badge",
|
||||
"width": "fill_container",
|
||||
"height": "fit_content",
|
||||
"fill": "#F7F6F3",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"padding": [8, 12, 8, 12],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "missing_path_text",
|
||||
"content": "~/Laputa",
|
||||
"fill": "#787774",
|
||||
"fontFamily": "SF Mono, monospace",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "missing_divider",
|
||||
"width": "fill_container",
|
||||
"height": 1,
|
||||
"fill": "#E9E9E7"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "missing_actions",
|
||||
"name": "Actions",
|
||||
"layout": "vertical",
|
||||
"gap": 12,
|
||||
"width": "fill_container",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "btn_recreate_vault",
|
||||
"name": "Create Getting Started Vault Button",
|
||||
"width": "fill_container",
|
||||
"height": 44,
|
||||
"fill": "#2383E2",
|
||||
"cornerRadius": [8, 8, 8, 8],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_recreate_icon",
|
||||
"content": "+",
|
||||
"fill": "#FFFFFF",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_recreate_label",
|
||||
"content": "Create Getting Started vault",
|
||||
"fill": "#FFFFFF",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "btn_choose_folder",
|
||||
"name": "Choose Different Folder Button",
|
||||
"width": "fill_container",
|
||||
"height": 44,
|
||||
"fill": "#FFFFFF",
|
||||
"stroke": "#E9E9E7",
|
||||
"strokeThickness": 1,
|
||||
"cornerRadius": [8, 8, 8, 8],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_choose_icon",
|
||||
"content": "📂",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "btn_choose_label",
|
||||
"content": "Choose a different folder",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
258
design/github-oauth-fix.pen
Normal file
@@ -0,0 +1,258 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix01",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"name": "GitHub OAuth Fix — Modal with inline device flow",
|
||||
"clip": true,
|
||||
"width": 560,
|
||||
"height": 420,
|
||||
"fill": "$--background",
|
||||
"cornerRadius": 12,
|
||||
"layout": "vertical",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix01-hdr",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"height": 56,
|
||||
"fill": "$--background",
|
||||
"padding": [0, 24],
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix01-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
|
||||
{ "type": "frame", "id": "gofix01-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix01-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix01-desc",
|
||||
"name": "Description",
|
||||
"width": "fill_container",
|
||||
"padding": [16, 24],
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix01-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix01-body",
|
||||
"name": "DeviceFlowLogin",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": [24, 24],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix01-login-btn",
|
||||
"name": "LoginButton",
|
||||
"width": 200,
|
||||
"height": 36,
|
||||
"fill": "$--foreground",
|
||||
"cornerRadius": 6,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix01-gh-icon", "content": "G", "fontSize": 14, "fontWeight": 700, "fill": "$--background" },
|
||||
{ "type": "text", "id": "gofix01-login-label", "content": "Login with GitHub", "fontSize": 13, "fontWeight": 500, "fill": "$--background" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02",
|
||||
"x": 660,
|
||||
"y": 0,
|
||||
"name": "GitHub OAuth Fix — Device code waiting state",
|
||||
"clip": true,
|
||||
"width": 560,
|
||||
"height": 420,
|
||||
"fill": "$--background",
|
||||
"cornerRadius": 12,
|
||||
"layout": "vertical",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-hdr",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"height": 56,
|
||||
"fill": "$--background",
|
||||
"padding": [0, 24],
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
|
||||
{ "type": "frame", "id": "gofix02-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix02-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-desc",
|
||||
"name": "Description",
|
||||
"width": "fill_container",
|
||||
"padding": [16, 24],
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-body",
|
||||
"name": "DeviceCodeCard",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 12,
|
||||
"padding": [24, 24],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-card",
|
||||
"name": "CodeCard",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [16, 24],
|
||||
"cornerRadius": 8,
|
||||
"alignItems": "center",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-hint", "content": "Enter this code on GitHub:", "fontSize": 12, "fill": "$--muted-foreground" },
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-code-row",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-code", "content": "ABCD-1234", "fontSize": 24, "fontWeight": 700, "letterSpacing": 4, "fontFamily": "monospace", "fill": "$--foreground" },
|
||||
{ "type": "text", "id": "gofix02-copy", "content": "[copy]", "fontSize": 12, "fill": "$--muted-foreground" }
|
||||
]
|
||||
},
|
||||
{ "type": "text", "id": "gofix02-url", "content": "https://github.com/login/device", "fontSize": 12, "fill": "$--muted-foreground", "textDecoration": "underline" },
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-spinner-row",
|
||||
"gap": 6,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-waiting", "content": "Waiting for authorization...", "fontSize": 12, "fill": "$--muted-foreground" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix02-cancel-btn",
|
||||
"name": "CancelButton",
|
||||
"width": 80,
|
||||
"height": 32,
|
||||
"cornerRadius": 6,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix02-cancel-label", "content": "Cancel", "fontSize": 12, "fill": "$--muted-foreground" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03",
|
||||
"x": 1320,
|
||||
"y": 0,
|
||||
"name": "GitHub OAuth Fix — Expired/Error state with retry",
|
||||
"clip": true,
|
||||
"width": 560,
|
||||
"height": 420,
|
||||
"fill": "$--background",
|
||||
"cornerRadius": 12,
|
||||
"layout": "vertical",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-hdr",
|
||||
"name": "Header",
|
||||
"width": "fill_container",
|
||||
"height": 56,
|
||||
"fill": "$--background",
|
||||
"padding": [0, 24],
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix03-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
|
||||
{ "type": "frame", "id": "gofix03-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix03-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-desc",
|
||||
"name": "Description",
|
||||
"width": "fill_container",
|
||||
"padding": [16, 24],
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix03-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-body",
|
||||
"name": "ErrorState",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": [24, 24],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-login-btn",
|
||||
"name": "LoginButton",
|
||||
"width": 200,
|
||||
"height": 36,
|
||||
"fill": "$--foreground",
|
||||
"cornerRadius": 6,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix03-gh-icon", "content": "G", "fontSize": 14, "fontWeight": 700, "fill": "$--background" },
|
||||
{ "type": "text", "id": "gofix03-login-label", "content": "Login with GitHub", "fontSize": 13, "fontWeight": 500, "fill": "$--background" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "gofix03-error-row",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "text", "id": "gofix03-error-msg", "content": "Authorization expired. Please try again.", "fontSize": 12, "fill": "$--destructive" },
|
||||
{ "type": "text", "id": "gofix03-retry-label", "content": "Retry", "fontSize": 12, "fill": "$--destructive" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
1
design/gs-git-init.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[{"type":"frame","id":"gs_git_init_after","name":"Getting Started Vault — Git Initialized","x":0,"y":0,"width":600,"height":"fit_content(200)","fill":"#FFFFFF","layout":"vertical","gap":16,"padding":[32,32],"children":[{"type":"text","id":"gs_title","content":"After: Getting Started vault created","fontFamily":"Inter","fontSize":20,"fontWeight":"700","fill":"#111111"},{"type":"text","id":"gs_desc","content":"When a new Getting Started vault is created, it is automatically initialized as a git repo with an initial commit containing all sample files.","fontFamily":"Inter","fontSize":14,"lineHeight":1.5,"fill":"#555555"},{"type":"frame","id":"gs_terminal","name":"Git Log Output","width":"fill_container","height":"fit_content(80)","fill":"#1E1E1E","cornerRadius":[8,8,8,8],"padding":[16,16],"layout":"vertical","gap":4,"children":[{"type":"text","id":"gs_prompt","content":"$ git log --oneline","fontFamily":"JetBrains Mono, monospace","fontSize":12,"fill":"#AAAAAA"},{"type":"text","id":"gs_log","content":"a1b2c3d Initial vault setup","fontFamily":"JetBrains Mono, monospace","fontSize":12,"fill":"#66FF66"}]},{"type":"frame","id":"gs_details","name":"Details","width":"fill_container","layout":"vertical","gap":8,"children":[{"type":"text","id":"gs_detail1","content":"git init + git add . + git commit","fontFamily":"Inter","fontSize":13,"fill":"#333333"},{"type":"text","id":"gs_detail2","content":"Fallback author: Laputa <vault@laputa.app> (if no global git config)","fontFamily":"Inter","fontSize":13,"fill":"#333333"},{"type":"text","id":"gs_detail3","content":"No UI changes — backend only (git.rs + getting_started.rs)","fontFamily":"Inter","fontSize":13,"fill":"#333333"}]}]}],"variables":{}}
|
||||
17
design/gs-wikilinks-fix.pen
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"name": "Getting Started Wikilinks — Fixed (styled with type color)",
|
||||
"type": "FRAME",
|
||||
"width": 800,
|
||||
"height": 400,
|
||||
"children": [
|
||||
{
|
||||
"name": "Description",
|
||||
"type": "TEXT",
|
||||
"characters": "Wikilinks in Getting Started vault now resolve correctly:\n- path/to/note targets match entry paths ending in /path/to/note.md\n- Display text shows entry title (not raw path)\n- Color reflects the linked note's type (e.g. blue for Note type)\n- Broken links show muted color as expected\n\nBefore: [[note/welcome-to-laputa]] showed raw path, unstyled\nAfter: [[note/welcome-to-laputa]] shows 'Welcome to Laputa' in Note blue"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
156
design/keyboard-first-nav.pen
Normal file
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_notelist_nav",
|
||||
"name": "Keyboard-first — Note list keyboard navigation",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 280,
|
||||
"height": "fit_content(400)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 0,
|
||||
"padding": [8, 0],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_notelist_label",
|
||||
"content": "Note list — ↑↓ highlight, Enter opens",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500",
|
||||
"padding": [0, 12, 8, 12]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_note_item_selected",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 48,
|
||||
"fill": "$--accent",
|
||||
"padding": [0, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_note_title_active",
|
||||
"content": "My selected note (keyboard highlight)",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_note_item_normal",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 48,
|
||||
"fill": "transparent",
|
||||
"padding": [0, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_note_title_normal",
|
||||
"content": "Another note",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_menu_bar",
|
||||
"name": "Keyboard-first — Menu bar shortcuts added",
|
||||
"x": 320,
|
||||
"y": 0,
|
||||
"width": 400,
|
||||
"height": "fit_content(200)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [16, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_menu_title",
|
||||
"content": "New menu bar shortcuts",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_s1",
|
||||
"content": "Archive Note — ⌘E\nTrash Note — ⌘⌫\nFind in Vault — ⌘⇧F\nGo Back — ⌘[\nGo Forward — ⌘]",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"lineHeight": 1.8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_inspector_tab",
|
||||
"name": "Keyboard-first — Inspector Tab+Enter navigation",
|
||||
"x": 760,
|
||||
"y": 0,
|
||||
"width": 300,
|
||||
"height": "fit_content(200)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [16, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_inspector_title",
|
||||
"content": "Inspector — Tab focuses rows, Enter starts editing",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_prop_focused",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4,
|
||||
"padding": [0, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_prop_key",
|
||||
"content": "Status",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_prop_val",
|
||||
"content": "In Progress (focused — ring visible)",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "nnc_pending_save",
|
||||
"name": "New Note Creation — Pending Save State",
|
||||
"name": "New Note Creation — Unsaved State (Cmd+N)",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 800,
|
||||
@@ -36,7 +36,7 @@
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "nnc_tab_active",
|
||||
"name": "Tab — New Note (unsaved)",
|
||||
"name": "Tab — Unsaved Note (blue dot + italic)",
|
||||
"layout": "horizontal",
|
||||
"width": 180,
|
||||
"height": 40,
|
||||
@@ -53,16 +53,17 @@
|
||||
"width": 8,
|
||||
"height": 8,
|
||||
"cornerRadius": 4,
|
||||
"fill": "#22c55e"
|
||||
"fill": "#3b82f6"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "nnc_tab_label",
|
||||
"content": "Untitled",
|
||||
"content": "Untitled Note",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "400"
|
||||
"fontWeight": "400",
|
||||
"fontStyle": "italic"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
design/note-list-title-position.pen
Normal file
1
design/note-templates.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/notes-type-icon-search.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
165
design/relationship-x-cosmetic.pen
Normal file
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rel_x_default",
|
||||
"name": "Relationship Pill — Default (X hidden)",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 280,
|
||||
"height": "fit_content(40)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [
|
||||
16,
|
||||
16
|
||||
],
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rel_x_default_label",
|
||||
"content": "Relationship Pill — Default State",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rel_x_pill_default",
|
||||
"layout": "horizontal",
|
||||
"gap": 4,
|
||||
"padding": [
|
||||
4,
|
||||
8
|
||||
],
|
||||
"fill": "$--accent",
|
||||
"borderRadius": 6,
|
||||
"width": "fit_content",
|
||||
"height": "fit_content",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rel_x_pill_label",
|
||||
"content": "Note Title",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "rel_x_type_icon",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"fill": "$--muted-foreground",
|
||||
"opacity": 0.5,
|
||||
"borderRadius": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rel_x_note_default",
|
||||
"content": "X button not visible in default state",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rel_x_hover",
|
||||
"name": "Relationship Pill — Hover (X visible)",
|
||||
"x": 320,
|
||||
"y": 0,
|
||||
"width": 280,
|
||||
"height": "fit_content(40)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [
|
||||
16,
|
||||
16
|
||||
],
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rel_x_hover_label",
|
||||
"content": "Relationship Pill — Hover State",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rel_x_pill_hover",
|
||||
"layout": "horizontal",
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
4,
|
||||
8
|
||||
],
|
||||
"fill": "$--accent",
|
||||
"borderRadius": 6,
|
||||
"width": "fit_content",
|
||||
"height": "fit_content",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rel_x_pill_label_hover",
|
||||
"content": "Note Title",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rel_x_trailing",
|
||||
"layout": "horizontal",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rel_x_icon",
|
||||
"content": "×",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "400"
|
||||
},
|
||||
{
|
||||
"type": "rectangle",
|
||||
"id": "rel_x_type_icon_hover",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"fill": "$--muted-foreground",
|
||||
"opacity": 0.5,
|
||||
"borderRadius": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rel_x_note_hover",
|
||||
"content": "X appears inline as flex child, before type icon. Ring border on hover.",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
665
design/search-results-subtitle.pen
Normal file
@@ -0,0 +1,665 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_main",
|
||||
"name": "Search Results — subtitle with metadata",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 540,
|
||||
"fill": "$--card",
|
||||
"cornerRadius": 12,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "$--border"
|
||||
},
|
||||
"shadow": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 8,
|
||||
"blur": 32,
|
||||
"spread": -4,
|
||||
"fill": "#00000020"
|
||||
}
|
||||
],
|
||||
"layout": "vertical",
|
||||
"clip": true,
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_inputRow",
|
||||
"name": "searchInputRow",
|
||||
"width": "fill_container",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"gap": 8,
|
||||
"padding": [14, 16],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "srs_searchIcon",
|
||||
"width": 18,
|
||||
"height": 18,
|
||||
"iconFontName": "magnifying-glass",
|
||||
"iconFontFamily": "phosphor",
|
||||
"fill": "$--primary"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_query",
|
||||
"fill": "$--foreground",
|
||||
"content": "time management",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_resultsHeader",
|
||||
"name": "resultsHeader",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 16],
|
||||
"alignItems": "center",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_count",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "3 results \u00b7 45ms",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_result1",
|
||||
"name": "searchResult1 (selected)",
|
||||
"width": "fill_container",
|
||||
"fill": "$--accent-blue-light",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r1_titleRow",
|
||||
"name": "titleRow",
|
||||
"width": "fill_container",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r1_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Time Management Strategies",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r1_badge",
|
||||
"name": "typeBadge",
|
||||
"fill": "$--accent-green-light",
|
||||
"cornerRadius": 4,
|
||||
"padding": [2, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r1_badgeTxt",
|
||||
"fill": "$--accent-green",
|
||||
"content": "Evergreen",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r1_meta",
|
||||
"name": "metadataRow",
|
||||
"width": "fill_container",
|
||||
"gap": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r1_date",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "2d ago",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r1_sep1",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u00b7",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r1_words",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "1,247 words",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r1_sep2",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u00b7",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r1_links",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "5 links",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_result2",
|
||||
"name": "searchResult2",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r2_titleRow",
|
||||
"name": "titleRow",
|
||||
"width": "fill_container",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Weekly Review Process",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r2_badge",
|
||||
"name": "typeBadge",
|
||||
"fill": "$--accent-purple-light",
|
||||
"cornerRadius": 4,
|
||||
"padding": [2, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_badgeTxt",
|
||||
"fill": "$--accent-purple",
|
||||
"content": "Procedure",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r2_meta",
|
||||
"name": "metadataRow",
|
||||
"width": "fill_container",
|
||||
"gap": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_date",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "5h ago",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_sep1",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u00b7",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_created",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Created Jan 15",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_sep2",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u00b7",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_words",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "856 words",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_sep3",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u00b7",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r2_links",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "12 links",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_result3",
|
||||
"name": "searchResult3",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16, 14, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r3_titleRow",
|
||||
"name": "titleRow",
|
||||
"width": "fill_container",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r3_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Q1 Planning",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_r3_meta",
|
||||
"name": "metadataRow",
|
||||
"width": "fill_container",
|
||||
"gap": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r3_date",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Jan 3",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r3_sep1",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u00b7",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_r3_words",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "2,103 words",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_footer",
|
||||
"name": "searchFooter",
|
||||
"width": "fill_container",
|
||||
"fill": "$--muted",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"top": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"gap": 16,
|
||||
"padding": [8, 16],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_hint1",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u2191\u2193 Navigate",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_hint2",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "\u21b5 Open",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_hint3",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Esc Close",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_states",
|
||||
"name": "Search Results — subtitle states",
|
||||
"x": 600,
|
||||
"y": 0,
|
||||
"width": 540,
|
||||
"layout": "vertical",
|
||||
"gap": 24,
|
||||
"padding": [24, 24],
|
||||
"fill": "$--background",
|
||||
"cornerRadius": 12,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "$--border"
|
||||
},
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_states_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Metadata Subtitle — States",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_state1",
|
||||
"name": "State: all metadata present",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s1_label",
|
||||
"fill": "$--accent-blue",
|
||||
"content": "All metadata present (modified \u2260 created)",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s1_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Time Management Strategies",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s1_meta",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "2d ago \u00b7 Created Jan 15 \u00b7 1,247 words \u00b7 5 links",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_state2",
|
||||
"name": "State: no created date (same as modified)",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s2_label",
|
||||
"fill": "$--accent-blue",
|
||||
"content": "Modified = Created (skip 'Created' field)",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s2_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Quick Note",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s2_meta",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "just now \u00b7 342 words \u00b7 2 links",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_state3",
|
||||
"name": "State: no links (zero outgoingLinks)",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s3_label",
|
||||
"fill": "$--accent-blue",
|
||||
"content": "No links (omitted from metadata)",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s3_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Random Thought",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s3_meta",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "3h ago \u00b7 128 words",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_state4",
|
||||
"name": "State: empty note (zero words)",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s4_label",
|
||||
"fill": "$--accent-blue",
|
||||
"content": "Empty note (word count shows 'Empty')",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s4_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Untitled Draft",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s4_meta",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "5d ago \u00b7 Empty",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "srs_state5",
|
||||
"name": "State: no date at all",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [10, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s5_label",
|
||||
"fill": "$--accent-blue",
|
||||
"content": "No date available (graceful degradation)",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s5_title",
|
||||
"fill": "$--foreground",
|
||||
"content": "Legacy Import",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "srs_s5_meta",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "89 words \u00b7 1 link",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
1
design/tags-property-type.pen
Normal file
1
design/theming-system.pen
Normal file
1
design/vault-agents-md.pen
Normal file
202
design/zoom-shortcuts.pen
Normal file
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_normal",
|
||||
"name": "Zoom Shortcuts — StatusBar (100% / default)",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"theme": { "Mode": "Light" },
|
||||
"width": 800,
|
||||
"height": 32,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"gap": 0,
|
||||
"padding": [0, 12],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_normal_desc",
|
||||
"name": "description",
|
||||
"width": "fill_container",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_normal_desc_text",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "StatusBar at 100% zoom — zoom indicator is HIDDEN. Cmd+= zooms in, Cmd+- zooms out, Cmd+0 resets to 100%.",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_zoomed",
|
||||
"name": "Zoom Shortcuts — StatusBar (150% / zoomed in)",
|
||||
"x": 0,
|
||||
"y": 60,
|
||||
"theme": { "Mode": "Light" },
|
||||
"width": 800,
|
||||
"height": 32,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"gap": 0,
|
||||
"padding": [0, 12],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_statusbar_zoomed",
|
||||
"name": "StatusBar — 150% (zoom indicator visible)",
|
||||
"layout": "horizontal",
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"padding": [0, 8],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_wordcount_z",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "1,247 words",
|
||||
"fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 11
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_zoom_badge",
|
||||
"name": "Zoom % Badge",
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4,
|
||||
"padding": [2, 6],
|
||||
"alignItems": "center",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_zoom_label",
|
||||
"fill": "$--foreground",
|
||||
"content": "150%",
|
||||
"fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_cmd_palette",
|
||||
"name": "Zoom Shortcuts — Command Palette entries",
|
||||
"x": 0,
|
||||
"y": 120,
|
||||
"theme": { "Mode": "Light" },
|
||||
"width": 540,
|
||||
"height": 120,
|
||||
"fill": "$--popover",
|
||||
"cornerRadius": 12,
|
||||
"layout": "vertical",
|
||||
"gap": 0,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_cmd_row1",
|
||||
"name": "Command — Zoom In (⌘=)",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 12],
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"fill": "$--accent",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_cmd_row1_label",
|
||||
"fill": "$--accent-foreground",
|
||||
"content": "Zoom In",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500",
|
||||
"width": "fill_container"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_cmd_row1_shortcut",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Command+=",
|
||||
"fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_cmd_row2",
|
||||
"name": "Command — Zoom Out (⌘-)",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 12],
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_cmd_row2_label",
|
||||
"fill": "$--foreground",
|
||||
"content": "Zoom Out",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500",
|
||||
"width": "fill_container"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_cmd_row2_shortcut",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Command+-",
|
||||
"fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "zs_cmd_row3",
|
||||
"name": "Command — Reset Zoom (⌘0)",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 12],
|
||||
"gap": 8,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_cmd_row3_label",
|
||||
"fill": "$--foreground",
|
||||
"content": "Reset Zoom",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500",
|
||||
"width": "fill_container"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zs_cmd_row3_shortcut",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Command+0",
|
||||
"fontFamily": "IBM Plex Mono",
|
||||
"fontSize": 11
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
@@ -131,29 +131,97 @@ The context picker controls which notes are sent to the AI as context:
|
||||
|
||||
### MCP Server
|
||||
|
||||
The MCP server (`mcp-server/`) exposes vault operations as tools:
|
||||
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `open_note` | Open and read a note by path |
|
||||
| `read_note` | Read note content (alias) |
|
||||
| `create_note` | Create new note with frontmatter |
|
||||
| `search_notes` | Search by title or content |
|
||||
| `append_to_note` | Append text to a note |
|
||||
#### Tool Surface (14 tools)
|
||||
|
||||
**Transports:**
|
||||
- **stdio** — standard MCP transport (`node mcp-server/index.js`)
|
||||
- **WebSocket** — live bridge for app integration (`node mcp-server/ws-bridge.js`, port 9710)
|
||||
| Tool | Params | Description |
|
||||
|------|--------|-------------|
|
||||
| `open_note` | `path` | Open and read a note by relative path |
|
||||
| `read_note` | `path` | Read note content (alias for `open_note`) |
|
||||
| `create_note` | `path, title, [is_a]` | Create new note with title and optional type frontmatter |
|
||||
| `search_notes` | `query, [limit]` | Search notes by title or content substring |
|
||||
| `append_to_note` | `path, text` | Append text to end of existing note |
|
||||
| `edit_note_frontmatter` | `path, patch` | Merge key-value patch into YAML frontmatter |
|
||||
| `delete_note` | `path` | Delete a note file from the vault |
|
||||
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
|
||||
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
|
||||
| `vault_context` | — | Get vault summary: entity types + 20 recent notes |
|
||||
| `ui_open_note` | `path` | Open a note in the Laputa UI editor |
|
||||
| `ui_open_tab` | `path` | Open a note in a new UI tab |
|
||||
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
|
||||
| `ui_set_filter` | `type` | Set the sidebar filter to a specific type |
|
||||
|
||||
#### Transports
|
||||
|
||||
- **stdio** — standard MCP transport for Claude Code / Cursor (`node mcp-server/index.js`)
|
||||
- **WebSocket** — live bridge for Laputa app integration:
|
||||
- Port **9710**: Tool bridge — AI/Claude clients call vault tools here
|
||||
- Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools
|
||||
|
||||
#### Auto-Registration
|
||||
|
||||
On app startup, Laputa automatically registers itself as an MCP server in:
|
||||
- `~/.claude/mcp.json` (Claude Code)
|
||||
- `~/.cursor/mcp.json` (Cursor)
|
||||
|
||||
The registration is non-destructive (additive — preserves other MCP servers) and uses `upsert` semantics. The entry points to `mcp-server/index.js` with the active vault path as `VAULT_PATH` env var.
|
||||
|
||||
Registration also runs from the frontend via the `useMcpRegistration` hook and `register_mcp_tools` Tauri command, ensuring the config stays up-to-date when the vault path changes.
|
||||
|
||||
#### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ MCP Server (Node.js) │
|
||||
│ │
|
||||
│ index.js ─── stdio transport ──→ Claude Code │
|
||||
│ │ Cursor │
|
||||
│ ├── vault.js (9 vault operations) │
|
||||
│ │ ├── findMarkdownFiles ├── deleteNote │
|
||||
│ │ ├── readNote ├── linkNotes │
|
||||
│ │ ├── createNote ├── listNotes │
|
||||
│ │ ├── searchNotes ├── vaultContext │
|
||||
│ │ ├── appendToNote │
|
||||
│ │ └── editNoteFrontmatter │
|
||||
│ │ │
|
||||
│ └── ws-bridge.js │
|
||||
│ ├── port 9710: tool bridge ←→ AI clients │
|
||||
│ └── port 9711: UI bridge ←→ Frontend │
|
||||
│ │
|
||||
│ Spawned by Tauri (mcp.rs) on app startup │
|
||||
│ Auto-registered in ~/.claude/mcp.json │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### WebSocket Bridge
|
||||
|
||||
The WebSocket bridge (`useMcpBridge` hook) enables real-time vault operations from the frontend:
|
||||
The WebSocket bridge enables real-time vault operations from both the frontend and external AI clients:
|
||||
|
||||
```
|
||||
Frontend (useMcpBridge) ←→ ws://localhost:9710 ←→ ws-bridge.js ←→ vault.js
|
||||
MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions
|
||||
```
|
||||
|
||||
Protocol: JSON-RPC-like with `{id, tool, args}` requests and `{id, result}` responses.
|
||||
**Tool bridge protocol** (port 9710):
|
||||
- Request: `{ "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }`
|
||||
- Response: `{ "id": "req-1", "result": { ... } }`
|
||||
- Error: `{ "id": "req-1", "error": "message" }`
|
||||
|
||||
**UI bridge protocol** (port 9711):
|
||||
- Broadcast: `{ "type": "ui_action", "action": "open_note", "path": "..." }`
|
||||
|
||||
### Rust MCP Module
|
||||
|
||||
`src-tauri/src/mcp.rs` manages the MCP server lifecycle:
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
|
||||
| `register_mcp(vault_path)` | Writes Laputa entry to Claude Code and Cursor MCP configs |
|
||||
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
|
||||
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
|
||||
|
||||
### Rust Backend (Tauri)
|
||||
|
||||
@@ -170,24 +238,31 @@ The `ai_chat` Tauri command (`src-tauri/src/ai_chat.rs`) provides a non-streamin
|
||||
| `src/components/AIChatPanel.tsx` | Main UI: context bar, messages, input, quick actions |
|
||||
| `src/hooks/useAIChat.ts` | Chat state: messages, streaming, send/retry/clear |
|
||||
| `src/hooks/useMcpBridge.ts` | WebSocket client for MCP vault tool calls |
|
||||
| `src/hooks/useMcpRegistration.ts` | Auto-registers Laputa MCP on vault load |
|
||||
| `src/utils/ai-chat.ts` | API client, token estimation, context builder |
|
||||
| `src-tauri/src/ai_chat.rs` | Rust Anthropic API client (non-streaming) |
|
||||
| `mcp-server/index.js` | MCP server entry (stdio transport) |
|
||||
| `mcp-server/vault.js` | Vault file operations |
|
||||
| `mcp-server/ws-bridge.js` | WebSocket bridge server |
|
||||
| `src-tauri/src/mcp.rs` | MCP server spawning + config registration |
|
||||
| `mcp-server/index.js` | MCP server entry (stdio transport, 14 tools) |
|
||||
| `mcp-server/vault.js` | Vault file operations (9 functions) |
|
||||
| `mcp-server/ws-bridge.js` | WebSocket bridge server (tool + UI bridges) |
|
||||
| `mcp-server/test.js` | 26 unit tests for all vault.js functions |
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Startup Sequence
|
||||
|
||||
```
|
||||
1. App mounts
|
||||
2. useVaultLoader fires:
|
||||
1. Tauri setup:
|
||||
a. run_startup_tasks() → purge trash, migrate frontmatter, register MCP config
|
||||
b. spawn_ws_bridge() → start MCP WebSocket bridge (ports 9710, 9711)
|
||||
2. App mounts
|
||||
3. useVaultLoader fires:
|
||||
a. isTauri() ? invoke('list_vault') : mockInvoke('list_vault')
|
||||
→ VaultEntry[] stored in state
|
||||
b. Load all content (mock mode) or on-demand (Tauri mode)
|
||||
c. invoke('get_modified_files') → ModifiedFile[] stored in state
|
||||
3. User clicks note in NoteList
|
||||
d. useMcpRegistration → invoke('register_mcp_tools') → ensures MCP config current
|
||||
4. User clicks note in NoteList
|
||||
4. useNoteActions.handleSelectNote:
|
||||
a. invoke('get_note_content') → raw markdown string
|
||||
b. Add tab { entry, content } to tabs state
|
||||
@@ -255,6 +330,7 @@ All commands are defined in `src-tauri/src/lib.rs` and registered via `tauri::ge
|
||||
| `git_commit` | `vault_path, message` | `String` | `git::git_commit()` |
|
||||
| `git_push` | `vault_path` | `String` | `git::git_push()` |
|
||||
| `ai_chat` | `request: AiChatRequest` | `AiChatResponse` | `ai_chat::send_chat()` |
|
||||
| `register_mcp_tools` | `vault_path` | `String` ("registered" or "updated") | `mcp::register_mcp()` |
|
||||
|
||||
All commands return `Result<T, String>`. Errors are serialized as JSON error objects to the frontend.
|
||||
|
||||
|
||||
@@ -6,11 +6,16 @@
|
||||
* VAULT_PATH=/path/to/vault node index.js
|
||||
*
|
||||
* Tools:
|
||||
* - open_note: Open and read a note by path
|
||||
* - read_note: Read note content (alias for consistency)
|
||||
* - open_note / read_note: Read a note by path
|
||||
* - create_note: Create a new note with title and optional frontmatter
|
||||
* - search_notes: Search notes by title or content
|
||||
* - append_to_note: Append text to an existing note
|
||||
* - edit_note_frontmatter: Merge a patch into a note's YAML frontmatter
|
||||
* - delete_note: Delete a note file
|
||||
* - link_notes: Add a title to an array property in a note's frontmatter
|
||||
* - list_notes: List all notes, optionally filtered by type
|
||||
* - vault_context: Get vault types and recent notes
|
||||
* - ui_open_note / ui_open_tab / ui_highlight / ui_set_filter: UI actions
|
||||
*/
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
@@ -18,9 +23,24 @@ import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import { readNote, createNote, searchNotes, appendToNote } from './vault.js'
|
||||
import {
|
||||
readNote, createNote, searchNotes, appendToNote,
|
||||
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
import { startUiBridge } from './ws-bridge.js'
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
|
||||
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions
|
||||
const uiBridge = startUiBridge(WS_UI_PORT)
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
|
||||
for (const client of uiBridge.clients) {
|
||||
if (client.readyState === 1) client.send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
@@ -82,6 +102,103 @@ const TOOLS = [
|
||||
required: ['path', 'text'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'edit_note_frontmatter',
|
||||
description: 'Merge a patch object into a note\'s YAML frontmatter',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note' },
|
||||
patch: { type: 'object', description: 'Key-value pairs to merge into frontmatter' },
|
||||
},
|
||||
required: ['path', 'patch'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delete_note',
|
||||
description: 'Delete a note file from the vault',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note to delete' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'link_notes',
|
||||
description: 'Add a target title to an array property in a note\'s frontmatter (e.g. add "Marco" to people: [])',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source_path: { type: 'string', description: 'Relative path to the source note' },
|
||||
property: { type: 'string', description: 'Frontmatter property name (e.g. "people", "tags")' },
|
||||
target_title: { type: 'string', description: 'Title to add to the array' },
|
||||
},
|
||||
required: ['source_path', 'property', 'target_title'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_notes',
|
||||
description: 'List all notes in the vault, optionally filtered by type frontmatter field',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type_filter: { type: 'string', description: 'Filter by type frontmatter value' },
|
||||
sort: { type: 'string', enum: ['title', 'mtime'], description: 'Sort order (default: title)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'vault_context',
|
||||
description: 'Get vault context: unique entity types and 20 most recently modified notes',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
name: 'ui_open_note',
|
||||
description: 'Open a note in the Laputa UI editor',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ui_open_tab',
|
||||
description: 'Open a note in a new tab in the Laputa UI',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ui_highlight',
|
||||
description: 'Highlight a UI element in the Laputa interface',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'UI element to highlight' },
|
||||
path: { type: 'string', description: 'Relative path to the note (optional)' },
|
||||
},
|
||||
required: ['element'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ui_set_filter',
|
||||
description: 'Set the sidebar filter to show notes of a specific type',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', description: 'Type to filter by' },
|
||||
},
|
||||
required: ['type'],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
@@ -90,6 +207,15 @@ const TOOL_HANDLERS = {
|
||||
create_note: handleCreateNote,
|
||||
search_notes: handleSearchNotes,
|
||||
append_to_note: handleAppendToNote,
|
||||
edit_note_frontmatter: handleEditFrontmatter,
|
||||
delete_note: handleDeleteNote,
|
||||
link_notes: handleLinkNotes,
|
||||
list_notes: handleListNotes,
|
||||
vault_context: handleVaultContext,
|
||||
ui_open_note: handleUiOpenNote,
|
||||
ui_open_tab: handleUiOpenTab,
|
||||
ui_highlight: handleUiHighlight,
|
||||
ui_set_filter: handleUiSetFilter,
|
||||
}
|
||||
|
||||
async function handleReadNote(args) {
|
||||
@@ -117,6 +243,54 @@ async function handleAppendToNote(args) {
|
||||
return { content: [{ type: 'text', text: `Appended text to ${args.path}` }] }
|
||||
}
|
||||
|
||||
async function handleEditFrontmatter(args) {
|
||||
const updated = await editNoteFrontmatter(VAULT_PATH, args.path, args.patch)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(updated) }] }
|
||||
}
|
||||
|
||||
async function handleDeleteNote(args) {
|
||||
await deleteNote(VAULT_PATH, args.path)
|
||||
return { content: [{ type: 'text', text: `Deleted ${args.path}` }] }
|
||||
}
|
||||
|
||||
async function handleLinkNotes(args) {
|
||||
const arr = await linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title)
|
||||
return { content: [{ type: 'text', text: `${args.property}: [${arr.join(', ')}]` }] }
|
||||
}
|
||||
|
||||
async function handleListNotes(args) {
|
||||
const notes = await listNotes(VAULT_PATH, args.type_filter, args.sort)
|
||||
const text = notes.length === 0
|
||||
? 'No notes found.'
|
||||
: notes.map(n => `${n.title} (${n.path})${n.type ? ` [${n.type}]` : ''}`).join('\n')
|
||||
return { content: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
async function handleVaultContext() {
|
||||
const ctx = await vaultContext(VAULT_PATH)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
|
||||
}
|
||||
|
||||
function handleUiOpenNote(args) {
|
||||
broadcastUiAction('open_note', { path: args.path })
|
||||
return { content: [{ type: 'text', text: `Opening ${args.path} in UI` }] }
|
||||
}
|
||||
|
||||
function handleUiOpenTab(args) {
|
||||
broadcastUiAction('open_tab', { path: args.path })
|
||||
return { content: [{ type: 'text', text: `Opening tab for ${args.path}` }] }
|
||||
}
|
||||
|
||||
function handleUiHighlight(args) {
|
||||
broadcastUiAction('highlight', { element: args.element, path: args.path })
|
||||
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
|
||||
}
|
||||
|
||||
function handleUiSetFilter(args) {
|
||||
broadcastUiAction('set_filter', { type: args.type })
|
||||
return { content: [{ type: 'text', text: `Filter set to ${args.type}` }] }
|
||||
}
|
||||
|
||||
// --- Server setup ---
|
||||
|
||||
const server = new Server(
|
||||
|
||||
109
mcp-server/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
},
|
||||
@@ -110,6 +111,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
@@ -334,6 +344,19 @@
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
@@ -425,6 +448,18 @@
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -544,6 +579,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
||||
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-yaml": "^3.13.1",
|
||||
"kind-of": "^6.0.2",
|
||||
"section-matter": "^1.0.0",
|
||||
"strip-bom-string": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -637,6 +687,15 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
@@ -658,6 +717,19 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -670,6 +742,15 @@
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -902,6 +983,19 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"kind-of": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
@@ -1046,6 +1140,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -1055,6 +1155,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom-string": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
||||
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@ import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import { readNote, createNote, searchNotes, appendToNote, findMarkdownFiles } from './vault.js'
|
||||
import {
|
||||
readNote, createNote, searchNotes, appendToNote, findMarkdownFiles,
|
||||
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
|
||||
let tmpDir
|
||||
|
||||
before(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
|
||||
|
||||
// Create test vault structure
|
||||
await fs.mkdir(path.join(tmpDir, 'project'), { recursive: true })
|
||||
await fs.mkdir(path.join(tmpDir, 'note'), { recursive: true })
|
||||
|
||||
@@ -33,6 +35,19 @@ is_a: Note
|
||||
# Daily Log
|
||||
|
||||
Today I worked on the MCP server implementation.
|
||||
`)
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, 'project', 'second-project.md'), `---
|
||||
title: Second Project
|
||||
type: Project
|
||||
status: Draft
|
||||
belongs_to:
|
||||
- "[[project/test-project]]"
|
||||
---
|
||||
|
||||
# Second Project
|
||||
|
||||
Another project for testing list and context.
|
||||
`)
|
||||
})
|
||||
|
||||
@@ -43,9 +58,10 @@ after(async () => {
|
||||
describe('findMarkdownFiles', () => {
|
||||
it('should find all .md files recursively', async () => {
|
||||
const files = await findMarkdownFiles(tmpDir)
|
||||
assert.equal(files.length, 2)
|
||||
assert.equal(files.length, 3)
|
||||
assert.ok(files.some(f => f.endsWith('test-project.md')))
|
||||
assert.ok(files.some(f => f.endsWith('daily-log.md')))
|
||||
assert.ok(files.some(f => f.endsWith('second-project.md')))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,7 +116,7 @@ describe('searchNotes', () => {
|
||||
})
|
||||
|
||||
it('should respect limit', async () => {
|
||||
const results = await searchNotes(tmpDir, 'note', 1)
|
||||
const results = await searchNotes(tmpDir, 'project', 1)
|
||||
assert.ok(results.length <= 1)
|
||||
})
|
||||
})
|
||||
@@ -113,3 +129,139 @@ describe('appendToNote', () => {
|
||||
assert.ok(content.includes('Finished testing.'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('editNoteFrontmatter', () => {
|
||||
it('should merge a patch into frontmatter', async () => {
|
||||
const updated = await editNoteFrontmatter(tmpDir, 'project/test-project.md', { status: 'Completed', priority: 'High' })
|
||||
assert.equal(updated.status, 'Completed')
|
||||
assert.equal(updated.priority, 'High')
|
||||
assert.equal(updated.title, 'Test Project')
|
||||
})
|
||||
|
||||
it('should preserve existing frontmatter fields', async () => {
|
||||
const content = await readNote(tmpDir, 'project/test-project.md')
|
||||
assert.ok(content.includes('is_a: Project'))
|
||||
assert.ok(content.includes('status: Completed'))
|
||||
})
|
||||
|
||||
it('should throw for missing file', async () => {
|
||||
await assert.rejects(
|
||||
() => editNoteFrontmatter(tmpDir, 'nonexistent.md', { foo: 'bar' }),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteNote', () => {
|
||||
it('should delete an existing note', async () => {
|
||||
const delPath = 'note/to-delete.md'
|
||||
await createNote(tmpDir, delPath, 'To Delete')
|
||||
const absPath = path.join(tmpDir, delPath)
|
||||
|
||||
// Verify it exists
|
||||
await fs.access(absPath)
|
||||
|
||||
await deleteNote(tmpDir, delPath)
|
||||
|
||||
await assert.rejects(
|
||||
() => fs.access(absPath),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw for missing file', async () => {
|
||||
await assert.rejects(
|
||||
() => deleteNote(tmpDir, 'nonexistent.md'),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkNotes', () => {
|
||||
it('should add a target to an array property', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
await createNote(tmpDir, linkPath, 'Link Test', { is_a: 'Project' })
|
||||
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
assert.deepEqual(result, ['[[note/daily-log]]'])
|
||||
})
|
||||
|
||||
it('should not duplicate existing links', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
|
||||
await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
assert.equal(result.length, 1)
|
||||
})
|
||||
|
||||
it('should add multiple distinct links', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
|
||||
await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
|
||||
// Should have daily-log and test-project
|
||||
assert.ok(result.includes('[[note/daily-log]]'))
|
||||
assert.ok(result.includes('[[project/test-project]]'))
|
||||
assert.equal(result.length, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listNotes', () => {
|
||||
it('should list all notes sorted by title', async () => {
|
||||
const notes = await listNotes(tmpDir)
|
||||
assert.ok(notes.length >= 3)
|
||||
// Verify sorted by title
|
||||
for (let i = 1; i < notes.length; i++) {
|
||||
assert.ok(notes[i - 1].title.localeCompare(notes[i].title) <= 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('should filter by type', async () => {
|
||||
const projects = await listNotes(tmpDir, 'Project')
|
||||
assert.ok(projects.length >= 1)
|
||||
for (const n of projects) {
|
||||
assert.equal(n.type, 'Project')
|
||||
}
|
||||
})
|
||||
|
||||
it('should return empty for unknown type', async () => {
|
||||
const notes = await listNotes(tmpDir, 'UnknownType12345')
|
||||
assert.equal(notes.length, 0)
|
||||
})
|
||||
|
||||
it('should support mtime sorting', async () => {
|
||||
const notes = await listNotes(tmpDir, undefined, 'mtime')
|
||||
assert.ok(notes.length >= 1)
|
||||
// Just verify it returns results without crashing
|
||||
assert.ok(notes[0].path)
|
||||
assert.ok(notes[0].title)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vaultContext', () => {
|
||||
it('should return types, recent notes, and vault path', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.ok(Array.isArray(ctx.types))
|
||||
assert.ok(Array.isArray(ctx.recentNotes))
|
||||
assert.equal(ctx.vaultPath, tmpDir)
|
||||
})
|
||||
|
||||
it('should include known entity types', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.ok(ctx.types.includes('Project'))
|
||||
assert.ok(ctx.types.includes('Note'))
|
||||
})
|
||||
|
||||
it('should cap recent notes at 20', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.ok(ctx.recentNotes.length <= 20)
|
||||
})
|
||||
|
||||
it('should include path and title in recent notes', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
for (const note of ctx.recentNotes) {
|
||||
assert.ok(note.path)
|
||||
assert.ok(note.title)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import matter from 'gray-matter'
|
||||
|
||||
/**
|
||||
* Recursively find all .md files under a directory.
|
||||
@@ -104,6 +105,119 @@ export async function appendToNote(vaultPath, notePath, text) {
|
||||
await fs.writeFile(absPath, current + separator + text + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a patch object into a note's YAML frontmatter.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} notePath
|
||||
* @param {Record<string, unknown>} patch
|
||||
* @returns {Promise<Record<string, unknown>>} The updated frontmatter.
|
||||
*/
|
||||
export async function editNoteFrontmatter(vaultPath, notePath, patch) {
|
||||
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
|
||||
const raw = await fs.readFile(absPath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
Object.assign(parsed.data, patch)
|
||||
const updated = matter.stringify(parsed.content, parsed.data)
|
||||
await fs.writeFile(absPath, updated, 'utf-8')
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a note file.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} notePath
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteNote(vaultPath, notePath) {
|
||||
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
|
||||
await fs.unlink(absPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a target title to an array property in a note's frontmatter.
|
||||
* Creates the property as an array if it doesn't exist.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} sourcePath
|
||||
* @param {string} property
|
||||
* @param {string} targetTitle
|
||||
* @returns {Promise<string[]>} The updated array.
|
||||
*/
|
||||
export async function linkNotes(vaultPath, sourcePath, property, targetTitle) {
|
||||
const absPath = path.isAbsolute(sourcePath) ? sourcePath : path.join(vaultPath, sourcePath)
|
||||
const raw = await fs.readFile(absPath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
const current = Array.isArray(parsed.data[property]) ? parsed.data[property] : []
|
||||
if (!current.includes(targetTitle)) {
|
||||
current.push(targetTitle)
|
||||
}
|
||||
parsed.data[property] = current
|
||||
const updated = matter.stringify(parsed.content, parsed.data)
|
||||
await fs.writeFile(absPath, updated, 'utf-8')
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* List all notes in the vault, optionally filtered by type.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} [typeFilter]
|
||||
* @param {string} [sort] - 'title' or 'mtime' (default: 'title')
|
||||
* @returns {Promise<Array<{path: string, title: string, type: string|null}>>}
|
||||
*/
|
||||
export async function listNotes(vaultPath, typeFilter, sort = 'title') {
|
||||
const files = await findMarkdownFiles(vaultPath)
|
||||
const notes = await Promise.all(files.map(async (filePath) => {
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
const relativePath = path.relative(vaultPath, filePath)
|
||||
const title = parsed.data.title || extractTitle(raw, path.basename(filePath, '.md'))
|
||||
const type = parsed.data.type || parsed.data.is_a || null
|
||||
const stat = sort === 'mtime' ? await fs.stat(filePath) : null
|
||||
return { path: relativePath, title, type, mtime: stat?.mtimeMs ?? 0 }
|
||||
}))
|
||||
|
||||
const filtered = typeFilter
|
||||
? notes.filter(n => n.type === typeFilter)
|
||||
: notes
|
||||
|
||||
if (sort === 'mtime') {
|
||||
filtered.sort((a, b) => b.mtime - a.mtime)
|
||||
} else {
|
||||
filtered.sort((a, b) => a.title.localeCompare(b.title))
|
||||
}
|
||||
|
||||
return filtered.map(({ mtime: _mtime, ...rest }) => rest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vault context: unique types and 20 most recent notes.
|
||||
* @param {string} vaultPath
|
||||
* @returns {Promise<{types: string[], recentNotes: Array<{path: string, title: string, type: string|null}>, vaultPath: string}>}
|
||||
*/
|
||||
export async function vaultContext(vaultPath) {
|
||||
const files = await findMarkdownFiles(vaultPath)
|
||||
const typesSet = new Set()
|
||||
const notesWithMtime = []
|
||||
|
||||
for (const filePath of files) {
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
const type = parsed.data.type || parsed.data.is_a || null
|
||||
if (type) typesSet.add(type)
|
||||
const stat = await fs.stat(filePath)
|
||||
notesWithMtime.push({
|
||||
path: path.relative(vaultPath, filePath),
|
||||
title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')),
|
||||
type,
|
||||
mtime: stat.mtimeMs,
|
||||
})
|
||||
}
|
||||
|
||||
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
|
||||
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
|
||||
|
||||
return { types: [...typesSet].sort(), recentNotes, vaultPath }
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,19 +5,46 @@
|
||||
* Exposes vault operations over WebSocket so the Laputa app frontend
|
||||
* can invoke MCP tools in real-time without going through stdio.
|
||||
*
|
||||
* Usage:
|
||||
* VAULT_PATH=/path/to/vault WS_PORT=9710 node ws-bridge.js
|
||||
* Port 9710: Tool bridge — Claude/AI clients call vault tools here.
|
||||
* Port 9711: UI bridge — Frontend listens for UI action broadcasts.
|
||||
*
|
||||
* Protocol:
|
||||
* Usage:
|
||||
* VAULT_PATH=/path/to/vault WS_PORT=9710 WS_UI_PORT=9711 node ws-bridge.js
|
||||
*
|
||||
* Protocol (tool bridge):
|
||||
* Client sends: { "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }
|
||||
* Server sends: { "id": "req-1", "result": { ... } }
|
||||
* On error: { "id": "req-1", "error": "message" }
|
||||
*
|
||||
* Protocol (UI bridge):
|
||||
* Server broadcasts: { "type": "ui_action", "action": "open_note", "path": "..." }
|
||||
*/
|
||||
import { WebSocketServer } from 'ws'
|
||||
import { readNote, createNote, searchNotes, appendToNote } from './vault.js'
|
||||
import {
|
||||
readNote, createNote, searchNotes, appendToNote,
|
||||
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
|
||||
/** @type {WebSocketServer | null} */
|
||||
let uiBridge = null
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
if (!uiBridge) return
|
||||
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
|
||||
for (const client of uiBridge.clients) {
|
||||
if (client.readyState === 1) client.send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
function buildFrontmatter(args) {
|
||||
const fm = {}
|
||||
if (args.is_a) fm.is_a = args.is_a
|
||||
return fm
|
||||
}
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
open_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
|
||||
@@ -25,12 +52,15 @@ const TOOL_HANDLERS = {
|
||||
create_note: (args) => createNote(VAULT_PATH, args.path, args.title, buildFrontmatter(args)),
|
||||
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
|
||||
append_to_note: (args) => appendToNote(VAULT_PATH, args.path, args.text).then(() => ({ ok: true })),
|
||||
}
|
||||
|
||||
function buildFrontmatter(args) {
|
||||
const fm = {}
|
||||
if (args.is_a) fm.is_a = args.is_a
|
||||
return fm
|
||||
edit_note_frontmatter: (args) => editNoteFrontmatter(VAULT_PATH, args.path, args.patch),
|
||||
delete_note: (args) => deleteNote(VAULT_PATH, args.path).then(() => ({ ok: true })),
|
||||
link_notes: (args) => linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title),
|
||||
list_notes: (args) => listNotes(VAULT_PATH, args.type_filter, args.sort),
|
||||
vault_context: () => vaultContext(VAULT_PATH),
|
||||
ui_open_note: (args) => { broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
|
||||
ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
|
||||
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
ui_set_filter: (args) => { broadcastUiAction('set_filter', { type: args.type }); return { ok: true } },
|
||||
}
|
||||
|
||||
async function handleMessage(data) {
|
||||
@@ -50,6 +80,17 @@ async function handleMessage(data) {
|
||||
}
|
||||
}
|
||||
|
||||
export function startUiBridge(port = WS_UI_PORT) {
|
||||
uiBridge = new WebSocketServer({ port })
|
||||
|
||||
uiBridge.on('connection', () => {
|
||||
console.error(`[ws-bridge] UI client connected on port ${port}`)
|
||||
})
|
||||
|
||||
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
|
||||
return uiBridge
|
||||
}
|
||||
|
||||
export function startBridge(port = WS_PORT) {
|
||||
const wss = new WebSocketServer({ port })
|
||||
|
||||
@@ -75,5 +116,6 @@ export function startBridge(port = WS_PORT) {
|
||||
// Run directly if invoked as main module
|
||||
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
|
||||
if (isMain) {
|
||||
startUiBridge()
|
||||
startBridge()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
fn main() {
|
||||
let count = std::process::Command::new("git")
|
||||
.args(["rev-list", "--count", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "DEV".to_string());
|
||||
|
||||
println!("cargo:rustc-env=BUILD_NUMBER={}", count);
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 39 KiB |
BIN
src-tauri/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.1 KiB |
BIN
src-tauri/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 516 KiB After Width: | Height: | Size: 559 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 665 B |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 161 KiB |
842
src-tauri/src/claude_cli.rs
Normal file
@@ -0,0 +1,842 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Status returned by `check_claude_cli`.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ClaudeCliStatus {
|
||||
pub installed: bool,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Event emitted to the frontend during a streaming claude session.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum ClaudeStreamEvent {
|
||||
/// Session initialised — carries the session ID for future `--resume`.
|
||||
Init { session_id: String },
|
||||
/// Incremental text chunk.
|
||||
TextDelta { text: String },
|
||||
/// A tool call started (agent mode only).
|
||||
ToolStart { tool_name: String, tool_id: String },
|
||||
/// A tool call finished (agent mode only).
|
||||
ToolDone { tool_id: String },
|
||||
/// Final result text + session ID.
|
||||
Result { text: String, session_id: String },
|
||||
/// Something went wrong.
|
||||
Error { message: String },
|
||||
/// Stream finished.
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Parameters accepted by `stream_claude_chat`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChatStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters accepted by `stream_claude_agent`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finding the `claude` binary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
|
||||
// Try `which claude` first (works when PATH is inherited).
|
||||
let output = Command::new("which")
|
||||
.arg("claude")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run `which claude`: {e}"))?;
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check common install locations.
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let candidates = [
|
||||
home.join(".local/bin/claude"),
|
||||
home.join(".npm/bin/claude"),
|
||||
PathBuf::from("/usr/local/bin/claude"),
|
||||
PathBuf::from("/opt/homebrew/bin/claude"),
|
||||
];
|
||||
for p in &candidates {
|
||||
if p.exists() {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Err("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code".into())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check whether the `claude` CLI is installed and return its version.
|
||||
pub fn check_cli() -> ClaudeCliStatus {
|
||||
let bin = match find_claude_binary() {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
return ClaudeCliStatus {
|
||||
installed: false,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let version = Command::new(&bin)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
|
||||
|
||||
ClaudeCliStatus {
|
||||
installed: true,
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn `claude -p` for a simple chat (no tools) and stream events via the
|
||||
/// provided callback. Returns the session ID for future `--resume` calls.
|
||||
pub fn run_chat_stream<F>(req: ChatStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let bin = find_claude_binary()?;
|
||||
let args = build_chat_args(&req);
|
||||
run_claude_subprocess(&bin, &args, &mut emit)
|
||||
}
|
||||
|
||||
/// Build CLI arguments for a chat stream request.
|
||||
fn build_chat_args(req: &ChatStreamRequest) -> Vec<String> {
|
||||
let mut args: Vec<String> = vec![
|
||||
"-p".into(),
|
||||
req.message.clone(),
|
||||
"--output-format".into(),
|
||||
"stream-json".into(),
|
||||
"--verbose".into(),
|
||||
"--include-partial-messages".into(),
|
||||
"--tools".into(),
|
||||
String::new(), // empty string → disable all built-in tools
|
||||
];
|
||||
|
||||
if let Some(ref sp) = req.system_prompt {
|
||||
if !sp.is_empty() {
|
||||
args.push("--system-prompt".into());
|
||||
args.push(sp.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref sid) = req.session_id {
|
||||
args.push("--resume".into());
|
||||
args.push(sid.clone());
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
/// Spawn `claude -p` with MCP vault tools for an agent task and stream events.
|
||||
pub fn run_agent_stream<F>(req: AgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let bin = find_claude_binary()?;
|
||||
let args = build_agent_args(&req)?;
|
||||
run_claude_subprocess(&bin, &args, &mut emit)
|
||||
}
|
||||
|
||||
/// Build CLI arguments for an agent stream request.
|
||||
fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
let mcp_config = build_mcp_config(&req.vault_path)?;
|
||||
|
||||
let mut args: Vec<String> = vec![
|
||||
"-p".into(),
|
||||
req.message.clone(),
|
||||
"--output-format".into(),
|
||||
"stream-json".into(),
|
||||
"--verbose".into(),
|
||||
"--include-partial-messages".into(),
|
||||
"--tools".into(),
|
||||
String::new(), // disable built-in tools; MCP tools remain
|
||||
"--mcp-config".into(),
|
||||
mcp_config,
|
||||
"--dangerously-skip-permissions".into(),
|
||||
"--no-session-persistence".into(),
|
||||
];
|
||||
|
||||
if let Some(ref sp) = req.system_prompt {
|
||||
if !sp.is_empty() {
|
||||
args.push("--append-system-prompt".into());
|
||||
args.push(sp.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a temporary MCP config JSON string pointing to the vault MCP server.
|
||||
fn build_mcp_config(vault_path: &str) -> Result<String, String> {
|
||||
let server_dir = crate::mcp::mcp_server_dir()?;
|
||||
let index_js = server_dir.join("index.js");
|
||||
let config = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"laputa": {
|
||||
"command": "node",
|
||||
"args": [index_js.to_string_lossy()],
|
||||
"env": { "VAULT_PATH": vault_path }
|
||||
}
|
||||
}
|
||||
});
|
||||
serde_json::to_string(&config).map_err(|e| format!("Failed to serialise MCP config: {e}"))
|
||||
}
|
||||
|
||||
/// Core subprocess runner shared by chat and agent modes.
|
||||
fn run_claude_subprocess<F>(bin: &PathBuf, args: &[String], emit: &mut F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let mut child = Command::new(bin)
|
||||
.args(args)
|
||||
.env_remove("CLAUDECODE") // prevent "nested session" guard
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
|
||||
let mut session_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
emit(ClaudeStreamEvent::Error {
|
||||
message: format!("Read error: {e}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue, // skip non-JSON lines
|
||||
};
|
||||
|
||||
dispatch_event(&json, &mut session_id, emit);
|
||||
}
|
||||
|
||||
// Read stderr for potential error messages.
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|s| std::io::read_to_string(s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
|
||||
|
||||
if !status.success() && session_id.is_empty() {
|
||||
let msg = if stderr_output.contains("not logged in")
|
||||
|| stderr_output.contains("authentication")
|
||||
|| stderr_output.contains("auth")
|
||||
{
|
||||
"Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into()
|
||||
} else if stderr_output.is_empty() {
|
||||
format!("claude exited with status {status}")
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
};
|
||||
emit(ClaudeStreamEvent::Error { message: msg });
|
||||
}
|
||||
|
||||
emit(ClaudeStreamEvent::Done);
|
||||
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// Parse a single JSON line from the stream and emit the appropriate event.
|
||||
fn dispatch_event<F>(json: &serde_json::Value, session_id: &mut String, emit: &mut F)
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let msg_type = json["type"].as_str().unwrap_or("");
|
||||
|
||||
match msg_type {
|
||||
// --- System init → capture session_id ---
|
||||
"system" if json["subtype"].as_str() == Some("init") => {
|
||||
if let Some(sid) = json["session_id"].as_str() {
|
||||
*session_id = sid.to_string();
|
||||
emit(ClaudeStreamEvent::Init {
|
||||
session_id: sid.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Streaming partial events (text deltas, tool_use starts) ---
|
||||
"stream_event" => {
|
||||
dispatch_stream_event(json, emit);
|
||||
}
|
||||
|
||||
// --- Tool progress (agent mode) ---
|
||||
"tool_progress" => {
|
||||
if let (Some(name), Some(id)) =
|
||||
(json["tool_name"].as_str(), json["tool_use_id"].as_str())
|
||||
{
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Final result ---
|
||||
"result" => {
|
||||
let sid = json["session_id"].as_str().unwrap_or("").to_string();
|
||||
if !sid.is_empty() {
|
||||
*session_id = sid.clone();
|
||||
}
|
||||
let text = json["result"].as_str().unwrap_or("").to_string();
|
||||
emit(ClaudeStreamEvent::Result {
|
||||
text,
|
||||
session_id: sid,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Complete assistant message (fallback for text when no partials) ---
|
||||
"assistant" => {
|
||||
if let Some(content) = json["message"]["content"].as_array() {
|
||||
for block in content {
|
||||
if block["type"].as_str() == Some("tool_use") {
|
||||
if let (Some(id), Some(name)) =
|
||||
(block["id"].as_str(), block["name"].as_str())
|
||||
{
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => {} // ignore other event types
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a `stream_event` (partial assistant message).
|
||||
fn dispatch_stream_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let event = &json["event"];
|
||||
let event_type = event["type"].as_str().unwrap_or("");
|
||||
|
||||
match event_type {
|
||||
"content_block_delta" => {
|
||||
let delta = &event["delta"];
|
||||
if delta["type"].as_str() == Some("text_delta") {
|
||||
if let Some(text) = delta["text"].as_str() {
|
||||
emit(ClaudeStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"content_block_start" => {
|
||||
let block = &event["content_block"];
|
||||
if block["type"].as_str() == Some("tool_use") {
|
||||
if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) {
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn check_cli_returns_status() {
|
||||
let status = check_cli();
|
||||
if status.installed {
|
||||
assert!(status.version.is_some());
|
||||
} else {
|
||||
assert!(status.version.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_mcp_config_is_valid_json() {
|
||||
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
|
||||
let parsed: serde_json::Value = serde_json::from_str(&config_str).unwrap();
|
||||
assert!(parsed["mcpServers"]["laputa"]["command"].is_string());
|
||||
assert_eq!(
|
||||
parsed["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
|
||||
"/tmp/test-vault"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- dispatch_event / dispatch_stream_event ---
|
||||
|
||||
/// Run dispatch_event on the given JSON and return (session_id, events).
|
||||
fn run_dispatch(json: serde_json::Value) -> (String, Vec<ClaudeStreamEvent>) {
|
||||
let mut sid = String::new();
|
||||
let mut events = vec![];
|
||||
dispatch_event(&json, &mut sid, &mut |e| events.push(e));
|
||||
(sid, events)
|
||||
}
|
||||
|
||||
/// Run dispatch_event with a pre-set session_id.
|
||||
fn run_dispatch_with_sid(
|
||||
json: serde_json::Value,
|
||||
initial_sid: &str,
|
||||
) -> (String, Vec<ClaudeStreamEvent>) {
|
||||
let mut sid = initial_sid.to_string();
|
||||
let mut events = vec![];
|
||||
dispatch_event(&json, &mut sid, &mut |e| events.push(e));
|
||||
(sid, events)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_init() {
|
||||
let (sid, events) = run_dispatch(serde_json::json!({
|
||||
"type": "system", "subtype": "init", "session_id": "test-session-123"
|
||||
}));
|
||||
assert_eq!(sid, "test-session-123");
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::Init { session_id } if session_id == "test-session-123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_system_without_init_subtype_is_ignored() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({ "type": "system", "subtype": "other" }));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_system_init_without_session_id_is_ignored() {
|
||||
let (sid, events) =
|
||||
run_dispatch(serde_json::json!({ "type": "system", "subtype": "init" }));
|
||||
assert!(events.is_empty());
|
||||
assert!(sid.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_text_delta() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Hello" } }
|
||||
}));
|
||||
assert!(matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_tool_start() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_start", "index": 1, "content_block": { "type": "tool_use", "id": "tool_abc", "name": "read_note", "input": {} } }
|
||||
}));
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "read_note" && tool_id == "tool_abc")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_result() {
|
||||
let (sid, events) = run_dispatch(serde_json::json!({
|
||||
"type": "result", "subtype": "success", "result": "All done!", "session_id": "sess-456"
|
||||
}));
|
||||
assert_eq!(sid, "sess-456");
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::Result { text, session_id } if text == "All done!" && session_id == "sess-456")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_result_with_empty_session_id() {
|
||||
let (sid, events) = run_dispatch_with_sid(
|
||||
serde_json::json!({ "type": "result", "result": "text here" }),
|
||||
"prev-session",
|
||||
);
|
||||
assert_eq!(sid, "prev-session");
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::Result { text, .. } if text == "text here")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_tool_progress() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "tool_progress", "tool_name": "search_notes", "tool_use_id": "tool_xyz"
|
||||
}));
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tool_xyz")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_tool_progress_missing_fields_is_ignored() {
|
||||
let (_, events) =
|
||||
run_dispatch(serde_json::json!({ "type": "tool_progress", "tool_name": "x" }));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_assistant_with_tool_use() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "assistant",
|
||||
"message": { "content": [
|
||||
{ "type": "text", "text": "Let me search." },
|
||||
{ "type": "tool_use", "id": "tu_1", "name": "search_notes", "input": {} }
|
||||
] }
|
||||
}));
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tu_1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_assistant_without_content_is_noop() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({ "type": "assistant", "message": {} }));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_ignores_unknown() {
|
||||
let (_, events) =
|
||||
run_dispatch(serde_json::json!({ "type": "some_future_type", "data": 42 }));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_stream_event_non_text_delta_is_ignored() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{}" } }
|
||||
}));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_stream_event_non_tool_block_start_is_ignored() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" } }
|
||||
}));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_stream_event_unknown_type_is_ignored() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event", "event": { "type": "message_stop" }
|
||||
}));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
// --- run_claude_subprocess with mock scripts ---
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_mock_script(script: &str) -> (Result<String, String>, Vec<ClaudeStreamEvent>) {
|
||||
run_mock_script_with_args(script, &[])
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_mock_script_with_args(
|
||||
script: &str,
|
||||
args: &[String],
|
||||
) -> (Result<String, String>, Vec<ClaudeStreamEvent>) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("mock-claude");
|
||||
std::fs::write(&path, script).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut events = vec![];
|
||||
let result = run_claude_subprocess(&path, args, &mut |e| events.push(e));
|
||||
(result, events)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_parses_ndjson_stream() {
|
||||
let (result, events) = run_mock_script(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s1\"}'\n",
|
||||
"echo '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}}'\n",
|
||||
"echo '{\"type\":\"result\",\"result\":\"Done\",\"session_id\":\"s1\"}'\n",
|
||||
));
|
||||
assert_eq!(result.unwrap(), "s1");
|
||||
assert!(matches!(&events[0], ClaudeStreamEvent::Init { session_id } if session_id == "s1"));
|
||||
assert!(matches!(&events[1], ClaudeStreamEvent::TextDelta { text } if text == "Hi"));
|
||||
assert!(matches!(&events[2], ClaudeStreamEvent::Result { .. }));
|
||||
assert!(matches!(&events[3], ClaudeStreamEvent::Done));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_skips_blank_and_non_json_lines() {
|
||||
let (result, events) = run_mock_script(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo ''\n",
|
||||
"echo 'not json at all'\n",
|
||||
"echo '{\"type\":\"result\",\"result\":\"ok\",\"session_id\":\"s2\"}'\n",
|
||||
));
|
||||
assert_eq!(result.unwrap(), "s2");
|
||||
assert!(matches!(&events[0], ClaudeStreamEvent::Result { text, .. } if text == "ok"));
|
||||
assert!(matches!(&events[1], ClaudeStreamEvent::Done));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_emits_error_on_nonzero_exit() {
|
||||
let (_, events) = run_mock_script("#!/bin/sh\necho 'auth problem' >&2\nexit 1\n");
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, ClaudeStreamEvent::Error { .. })));
|
||||
assert!(matches!(events.last().unwrap(), ClaudeStreamEvent::Done));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_detects_auth_error_in_stderr() {
|
||||
let (_, events) = run_mock_script("#!/bin/sh\necho 'not logged in' >&2\nexit 1\n");
|
||||
assert!(events.iter().any(|e| matches!(e, ClaudeStreamEvent::Error { message } if message.contains("not authenticated"))));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_reports_exit_code_on_empty_stderr() {
|
||||
let (_, events) = run_mock_script("#!/bin/sh\nexit 2\n");
|
||||
assert!(events.iter().any(
|
||||
|e| matches!(e, ClaudeStreamEvent::Error { message } if message.contains("exited with"))
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_success_with_no_events() {
|
||||
let (result, events) = run_mock_script("#!/bin/sh\nexit 0\n");
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(events.last().unwrap(), ClaudeStreamEvent::Done));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_passes_args_through() {
|
||||
let args: Vec<String> = vec!["--foo".into(), "bar".into()];
|
||||
let (_, events) = run_mock_script_with_args(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"$*\\\",\\\"session_id\\\":\\\"sx\\\"}\"\n",
|
||||
), &args);
|
||||
let text = events.iter().find_map(|e| match e {
|
||||
ClaudeStreamEvent::Result { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
assert!(text.unwrap().contains("--foo"));
|
||||
}
|
||||
|
||||
// --- build_chat_args ---
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_basic() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hello".into(),
|
||||
system_prompt: None,
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"-p".to_string()));
|
||||
assert!(args.contains(&"hello".to_string()));
|
||||
assert!(args.contains(&"stream-json".to_string()));
|
||||
assert!(!args.contains(&"--system-prompt".to_string()));
|
||||
assert!(!args.contains(&"--resume".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_with_system_prompt() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hi".into(),
|
||||
system_prompt: Some("You are helpful.".into()),
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"--system-prompt".to_string()));
|
||||
assert!(args.contains(&"You are helpful.".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_empty_system_prompt_is_skipped() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hi".into(),
|
||||
system_prompt: Some(String::new()),
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(!args.contains(&"--system-prompt".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_with_session_id() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "continue".into(),
|
||||
system_prompt: None,
|
||||
session_id: Some("sess-abc".into()),
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"--resume".to_string()));
|
||||
assert!(args.contains(&"sess-abc".to_string()));
|
||||
}
|
||||
|
||||
// --- build_agent_args ---
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_basic() {
|
||||
// build_agent_args calls build_mcp_config which needs mcp_server_dir
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "create note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
}) {
|
||||
assert!(args.contains(&"-p".to_string()));
|
||||
assert!(args.contains(&"create note".to_string()));
|
||||
assert!(args.contains(&"--mcp-config".to_string()));
|
||||
assert!(args.contains(&"--dangerously-skip-permissions".to_string()));
|
||||
assert!(args.contains(&"--no-session-persistence".to_string()));
|
||||
assert!(!args.contains(&"--append-system-prompt".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_with_system_prompt() {
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "do it".into(),
|
||||
system_prompt: Some("Act as expert.".into()),
|
||||
vault_path: "/tmp/v".into(),
|
||||
}) {
|
||||
assert!(args.contains(&"--append-system-prompt".to_string()));
|
||||
assert!(args.contains(&"Act as expert.".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_empty_system_prompt_is_skipped() {
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "x".into(),
|
||||
system_prompt: Some(String::new()),
|
||||
vault_path: "/tmp/v".into(),
|
||||
}) {
|
||||
assert!(!args.contains(&"--append-system-prompt".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// --- find_claude_binary ---
|
||||
|
||||
#[test]
|
||||
fn find_claude_binary_returns_result() {
|
||||
let result = find_claude_binary();
|
||||
// On dev machines claude may be installed; on CI it may not.
|
||||
// Either way, the function should return Ok(path) or Err(message).
|
||||
match &result {
|
||||
Ok(path) => assert!(path.exists()),
|
||||
Err(msg) => assert!(msg.contains("not found")),
|
||||
}
|
||||
}
|
||||
|
||||
// --- run_chat_stream / run_agent_stream error paths ---
|
||||
|
||||
#[test]
|
||||
fn run_chat_stream_returns_result() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "test".into(),
|
||||
system_prompt: None,
|
||||
session_id: None,
|
||||
};
|
||||
let mut events = vec![];
|
||||
// This will either succeed (if claude is installed) or fail (if not).
|
||||
let result = run_chat_stream(req, |e| events.push(e));
|
||||
// Either way the function should have returned without panicking.
|
||||
assert!(result.is_ok() || result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_agent_stream_returns_result() {
|
||||
let req = AgentStreamRequest {
|
||||
message: "test".into(),
|
||||
system_prompt: Some("sys".into()),
|
||||
vault_path: "/tmp/nonexistent".into(),
|
||||
};
|
||||
let mut events = vec![];
|
||||
let result = run_agent_stream(req, |e| events.push(e));
|
||||
assert!(result.is_ok() || result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_subprocess_spawn_failure() {
|
||||
let fake_bin = PathBuf::from("/nonexistent/binary/path");
|
||||
let mut events = vec![];
|
||||
let result = run_claude_subprocess(&fake_bin, &[], &mut |e| events.push(e));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Failed to spawn"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_with_tool_progress_and_assistant() {
|
||||
let (result, events) = run_mock_script(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s3\"}'\n",
|
||||
"echo '{\"type\":\"tool_progress\",\"tool_name\":\"search\",\"tool_use_id\":\"t1\"}'\n",
|
||||
"echo '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"t2\",\"name\":\"read\",\"input\":{}}]}}'\n",
|
||||
"echo '{\"type\":\"result\",\"result\":\"fin\",\"session_id\":\"s3\"}'\n",
|
||||
));
|
||||
assert_eq!(result.unwrap(), "s3");
|
||||
assert!(events.len() >= 4);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_success_exit_with_session_id_skips_error() {
|
||||
let (_, events) = run_mock_script(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s4\"}'\n",
|
||||
"echo 'some warning' >&2\n",
|
||||
"exit 1\n",
|
||||
));
|
||||
// Should NOT have an error event because session_id is non-empty
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, ClaudeStreamEvent::Error { .. })));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ pub enum FrontmatterValue {
|
||||
|
||||
/// Characters that require a YAML string value to be quoted.
|
||||
fn has_yaml_special_chars(s: &str) -> bool {
|
||||
s.contains(':') || s.contains('#') || s.contains('\n')
|
||||
s.contains(':') || s.contains('#')
|
||||
}
|
||||
|
||||
/// Check if a string starts with a YAML collection indicator (array or map).
|
||||
@@ -41,6 +41,23 @@ 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 {
|
||||
@@ -54,7 +71,9 @@ impl FrontmatterValue {
|
||||
pub fn to_yaml_value(&self) -> String {
|
||||
match self {
|
||||
FrontmatterValue::String(s) => {
|
||||
if needs_yaml_quoting(s) {
|
||||
if s.contains('\n') {
|
||||
format_block_scalar(s)
|
||||
} else if needs_yaml_quoting(s) {
|
||||
quote_yaml_string(s)
|
||||
} else {
|
||||
s.clone()
|
||||
@@ -113,16 +132,20 @@ fn line_is_key(line: &str, key: &str) -> bool {
|
||||
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.contains('\n') {
|
||||
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 is a YAML list continuation (` - ...`) rather than a new key.
|
||||
fn is_list_continuation(line: &str) -> bool {
|
||||
line.starts_with(" - ") || line.starts_with(" -\t")
|
||||
/// 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 `---`.
|
||||
@@ -163,8 +186,8 @@ fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue
|
||||
|
||||
found_key = true;
|
||||
i += 1;
|
||||
// Skip list continuation lines belonging to this key
|
||||
while i < lines.len() && is_list_continuation(lines[i]) {
|
||||
// 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)
|
||||
@@ -699,6 +722,94 @@ mod tests {
|
||||
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 ---
|
||||
|
||||
@@ -12,6 +12,54 @@ pub struct GitCommit {
|
||||
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)?;
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -80,7 +128,7 @@ 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"])
|
||||
.args(["status", "--porcelain", "--untracked-files=all"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {}", e))?;
|
||||
@@ -562,6 +610,57 @@ mod tests {
|
||||
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_get_file_history_with_commits() {
|
||||
let dir = setup_git_repo();
|
||||
@@ -641,7 +740,42 @@ mod tests {
|
||||
|
||||
assert!(modified.len() >= 2);
|
||||
let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect();
|
||||
assert!(statuses.contains(&"modified") || statuses.contains(&"untracked"));
|
||||
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]
|
||||
|
||||
@@ -1,34 +1,61 @@
|
||||
pub mod ai_chat;
|
||||
pub mod claude_cli;
|
||||
pub mod frontmatter;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
pub mod mcp;
|
||||
pub mod menu;
|
||||
pub mod search;
|
||||
pub mod settings;
|
||||
pub mod theme;
|
||||
pub mod vault;
|
||||
|
||||
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};
|
||||
use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
use search::SearchResponse;
|
||||
use settings::Settings;
|
||||
use theme::{ThemeFile, VaultSettings};
|
||||
use vault::{RenameResult, VaultEntry};
|
||||
|
||||
/// 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> {
|
||||
vault::scan_vault_cached(Path::new(&path))
|
||||
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> {
|
||||
vault::get_note_content(Path::new(&path))
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -38,26 +65,33 @@ fn update_frontmatter(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -67,26 +101,40 @@ fn get_file_diff_at_commit(
|
||||
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 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]
|
||||
fn get_build_number() -> String {
|
||||
{
|
||||
let n = std::env::var("BUILD_NUMBER").unwrap_or_else(|_| "0".to_string());
|
||||
format!("b{}", n)
|
||||
}
|
||||
}
|
||||
|
||||
#[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 git_push(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_push(&vault_path)
|
||||
}
|
||||
|
||||
@@ -95,35 +143,102 @@ 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 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 {
|
||||
frontmatter::update_frontmatter(path, "Archived", FrontmatterValue::Bool(true))?;
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
@@ -134,8 +249,13 @@ 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 {
|
||||
frontmatter::update_frontmatter(path, "Trashed", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(path, "Trashed at", FrontmatterValue::String(now.clone()))?;
|
||||
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)
|
||||
@@ -173,6 +293,7 @@ async fn github_create_repo(
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
@@ -198,12 +319,59 @@ async fn search_vault(
|
||||
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))?
|
||||
}
|
||||
|
||||
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]
|
||||
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: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::set_active_theme(&vault_path, &theme_id)
|
||||
}
|
||||
|
||||
#[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())
|
||||
}
|
||||
|
||||
fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
match result {
|
||||
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
|
||||
@@ -229,14 +397,68 @@ fn run_startup_tasks() {
|
||||
"Migrated is_a to type on startup",
|
||||
vault::migrate_is_a_to_type(vp_str),
|
||||
);
|
||||
|
||||
// Seed _themes/ with built-in themes if missing
|
||||
theme::seed_default_themes(vp_str);
|
||||
|
||||
// Register Laputa MCP server in Claude Code and Cursor configs
|
||||
match mcp::register_mcp(vp_str) {
|
||||
Ok(status) => log::info!("MCP registration: {status}"),
|
||||
Err(e) => log::warn!("MCP registration failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {}
|
||||
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 get_build_number_returns_prefixed_value() {
|
||||
let result = get_build_number();
|
||||
assert!(
|
||||
result.starts_with('b'),
|
||||
"expected 'b' prefix, got: {}",
|
||||
result
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(WsBridgeChild(Mutex::new(None)))
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
@@ -245,10 +467,10 @@ pub fn run() {
|
||||
.build(),
|
||||
)?;
|
||||
// Open devtools automatically in debug builds
|
||||
use tauri::Manager;
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.open_devtools();
|
||||
}
|
||||
// use tauri::Manager;
|
||||
// if let Some(window) = app.get_webview_window("main") {
|
||||
// window.open_devtools();
|
||||
// }
|
||||
}
|
||||
|
||||
app.handle().plugin(tauri_plugin_dialog::init())?;
|
||||
@@ -264,6 +486,22 @@ pub fn run() {
|
||||
|
||||
run_startup_tasks();
|
||||
|
||||
// Spawn the MCP WebSocket bridge for the default vault
|
||||
{
|
||||
use tauri::Manager;
|
||||
let vault_path = dirs::home_dir()
|
||||
.map(|h| h.join("Laputa"))
|
||||
.unwrap_or_default();
|
||||
let vp_str = vault_path.to_string_lossy().to_string();
|
||||
match mcp::spawn_ws_bridge(&vp_str) {
|
||||
Ok(child) => {
|
||||
let state: tauri::State<'_, WsBridgeChild> = app.state();
|
||||
*state.0.lock().unwrap() = Some(child);
|
||||
}
|
||||
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -278,11 +516,16 @@ pub fn run() {
|
||||
get_file_diff,
|
||||
get_file_diff_at_commit,
|
||||
git_commit,
|
||||
get_build_number,
|
||||
get_last_commit_info,
|
||||
git_pull,
|
||||
git_push,
|
||||
ai_chat,
|
||||
check_claude_cli,
|
||||
stream_claude_chat,
|
||||
stream_claude_agent,
|
||||
save_image,
|
||||
copy_image_to_vault,
|
||||
purge_trash,
|
||||
migrate_is_a_to_type,
|
||||
batch_archive_notes,
|
||||
@@ -296,8 +539,29 @@ pub fn run() {
|
||||
github_device_flow_start,
|
||||
github_device_flow_poll,
|
||||
github_get_user,
|
||||
search_vault
|
||||
search_vault,
|
||||
create_getting_started_vault,
|
||||
check_vault_exists,
|
||||
get_default_vault_path,
|
||||
register_mcp_tools,
|
||||
list_themes,
|
||||
get_theme,
|
||||
get_vault_settings,
|
||||
save_vault_settings,
|
||||
set_active_theme,
|
||||
create_theme
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
use tauri::Manager;
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.kill();
|
||||
log::info!("ws-bridge child process killed on exit");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
315
src-tauri/src/mcp.rs
Normal file
@@ -0,0 +1,315 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command};
|
||||
|
||||
/// Find the `node` binary path at runtime.
|
||||
pub(crate) fn find_node() -> Result<PathBuf, String> {
|
||||
let output = Command::new("which")
|
||||
.arg("node")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run `which node`: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err("node not found in PATH".into());
|
||||
}
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
Ok(PathBuf::from(path))
|
||||
}
|
||||
|
||||
/// Resolve the path to `mcp-server/`.
|
||||
///
|
||||
/// In dev mode, uses `CARGO_MANIFEST_DIR` (set at compile time).
|
||||
/// In release mode, navigates from the current executable.
|
||||
pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
|
||||
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("mcp-server");
|
||||
if dev_path.join("ws-bridge.js").exists() {
|
||||
return Ok(std::fs::canonicalize(&dev_path).unwrap_or(dev_path));
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?;
|
||||
let release_path = exe
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.join("mcp-server"))
|
||||
.ok_or_else(|| "Cannot resolve mcp-server directory".to_string())?;
|
||||
if release_path.join("ws-bridge.js").exists() {
|
||||
return Ok(release_path);
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"mcp-server not found at {} or {}",
|
||||
dev_path.display(),
|
||||
release_path.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Spawn the WebSocket bridge as a child process.
|
||||
pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
|
||||
let node = find_node()?;
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let script = server_dir.join("ws-bridge.js");
|
||||
|
||||
let child = Command::new(node)
|
||||
.arg(&script)
|
||||
.env("VAULT_PATH", vault_path)
|
||||
.env("WS_PORT", "9710")
|
||||
.env("WS_UI_PORT", "9711")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn ws-bridge: {e}"))?;
|
||||
|
||||
log::info!("ws-bridge spawned (pid: {})", child.id());
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
/// Build the MCP server entry JSON for a given vault path and index.js path.
|
||||
fn build_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"command": "node",
|
||||
"args": [index_js],
|
||||
"env": { "VAULT_PATH": vault_path }
|
||||
})
|
||||
}
|
||||
|
||||
/// Write MCP registration to a list of config file paths.
|
||||
/// Returns "registered" on first registration, "updated" if already present.
|
||||
fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf]) -> String {
|
||||
let mut status = "registered";
|
||||
for config_path in config_paths {
|
||||
match upsert_mcp_config(config_path, entry) {
|
||||
Ok(true) => status = "updated",
|
||||
Ok(false) => {}
|
||||
Err(e) => log::warn!("Failed to update {}: {}", config_path.display(), e),
|
||||
}
|
||||
}
|
||||
status.to_string()
|
||||
}
|
||||
|
||||
/// Register Laputa as an MCP server in Claude Code and Cursor config files.
|
||||
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
|
||||
|
||||
let entry = build_mcp_entry(&index_js, vault_path);
|
||||
|
||||
let configs: Vec<PathBuf> = [
|
||||
dirs::home_dir().map(|h| h.join(".claude").join("mcp.json")),
|
||||
dirs::home_dir().map(|h| h.join(".cursor").join("mcp.json")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
|
||||
Ok(register_mcp_to_configs(&entry, &configs))
|
||||
}
|
||||
|
||||
/// Insert or update the "laputa" entry in an MCP config file.
|
||||
fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bool, String> {
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?;
|
||||
}
|
||||
|
||||
let mut config: serde_json::Value = if config_path.exists() {
|
||||
let raw = std::fs::read_to_string(config_path)
|
||||
.map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?;
|
||||
serde_json::from_str(&raw)
|
||||
.map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display()))?
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
|
||||
let servers = config
|
||||
.as_object_mut()
|
||||
.ok_or("Config is not a JSON object")?
|
||||
.entry("mcpServers")
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
|
||||
let was_update = servers.get("laputa").is_some();
|
||||
|
||||
servers
|
||||
.as_object_mut()
|
||||
.ok_or("mcpServers is not a JSON object")?
|
||||
.insert("laputa".to_string(), entry.clone());
|
||||
|
||||
let json = serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("Failed to serialize config: {e}"))?;
|
||||
std::fs::write(config_path, json)
|
||||
.map_err(|e| format!("Cannot write {}: {e}", config_path.display()))?;
|
||||
|
||||
Ok(was_update)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_mcp_entry_produces_correct_json() {
|
||||
let entry = build_mcp_entry("/path/to/index.js", "/my/vault");
|
||||
assert_eq!(entry["command"], "node");
|
||||
assert_eq!(entry["args"][0], "/path/to/index.js");
|
||||
assert_eq!(entry["env"]["VAULT_PATH"], "/my/vault");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_creates_new_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/test/vault");
|
||||
|
||||
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(!was_update);
|
||||
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(config["mcpServers"]["laputa"]["args"][0], "/test/index.js");
|
||||
assert_eq!(
|
||||
config["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
|
||||
"/test/vault"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_updates_existing_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
|
||||
let entry1 = build_mcp_entry("/test/index.js", "/vault/v1");
|
||||
upsert_mcp_config(&config_path, &entry1).unwrap();
|
||||
|
||||
let entry2 = build_mcp_entry("/test/index.js", "/vault/v2");
|
||||
let was_update = upsert_mcp_config(&config_path, &entry2).unwrap();
|
||||
assert!(was_update);
|
||||
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(
|
||||
config["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
|
||||
"/vault/v2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_other_servers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
|
||||
let existing = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"other-server": { "command": "other", "args": [] }
|
||||
}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
|
||||
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert!(config["mcpServers"]["other-server"].is_object());
|
||||
assert!(config["mcpServers"]["laputa"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_creates_parent_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("nested").join("dir").join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
|
||||
upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
assert!(config_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_mcp_to_configs_returns_registered_for_new() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("claude").join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
|
||||
let status = register_mcp_to_configs(&entry, &[config]);
|
||||
assert_eq!(status, "registered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_mcp_to_configs_returns_updated_for_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
|
||||
// First call
|
||||
register_mcp_to_configs(&entry, &[config.clone()]);
|
||||
// Second call
|
||||
let status = register_mcp_to_configs(&entry, &[config]);
|
||||
assert_eq!(status, "updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_node_returns_valid_path() {
|
||||
let node = find_node().unwrap();
|
||||
assert!(node.exists(), "node binary should exist at {:?}", node);
|
||||
assert!(
|
||||
node.to_string_lossy().contains("node"),
|
||||
"path should contain 'node': {:?}",
|
||||
node
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_dir_resolves_in_dev() {
|
||||
let dir = mcp_server_dir().unwrap();
|
||||
assert!(dir.join("ws-bridge.js").exists());
|
||||
assert!(dir.join("index.js").exists());
|
||||
assert!(dir.join("vault.js").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_ws_bridge_starts_and_can_be_killed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let vault_path = tmp.path().to_str().unwrap();
|
||||
|
||||
let mut child = spawn_ws_bridge(vault_path).unwrap();
|
||||
assert!(child.id() > 0, "child process should have a valid PID");
|
||||
|
||||
// Clean up: kill the spawned process
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_mcp_to_configs_writes_multiple_configs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let claude_cfg = tmp.path().join("claude").join("mcp.json");
|
||||
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
|
||||
register_mcp_to_configs(&entry, &[claude_cfg.clone(), cursor_cfg.clone()]);
|
||||
|
||||
assert!(claude_cfg.exists());
|
||||
assert!(cursor_cfg.exists());
|
||||
|
||||
let raw = std::fs::read_to_string(&claude_cfg).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(config["mcpServers"]["laputa"]["args"][0], "/test/index.js");
|
||||
}
|
||||
#[test]
|
||||
fn upsert_returns_error_for_invalid_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
std::fs::write(&config_path, "not valid json{{{{").unwrap();
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let result = upsert_mcp_config(&config_path, &entry);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_mcp_to_configs_handles_empty_list() {
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
// Empty config list — function should return "registered" (no existing)
|
||||
let status = register_mcp_to_configs(&entry, &[]);
|
||||
// With empty config list, there were no updates, so status should be "registered"
|
||||
assert_eq!(status, "registered");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use tauri::{
|
||||
// Custom menu item IDs that emit events to the frontend.
|
||||
const APP_SETTINGS: &str = "app-settings";
|
||||
const FILE_NEW_NOTE: &str = "file-new-note";
|
||||
const FILE_DAILY_NOTE: &str = "file-daily-note";
|
||||
const FILE_QUICK_OPEN: &str = "file-quick-open";
|
||||
const FILE_SAVE: &str = "file-save";
|
||||
const FILE_CLOSE_TAB: &str = "file-close-tab";
|
||||
@@ -14,22 +15,39 @@ const VIEW_EDITOR_LIST: &str = "view-editor-list";
|
||||
const VIEW_ALL: &str = "view-all";
|
||||
const VIEW_TOGGLE_INSPECTOR: &str = "view-toggle-inspector";
|
||||
const VIEW_COMMAND_PALETTE: &str = "view-command-palette";
|
||||
const VIEW_ZOOM_IN: &str = "view-zoom-in";
|
||||
const VIEW_ZOOM_OUT: &str = "view-zoom-out";
|
||||
const VIEW_ZOOM_RESET: &str = "view-zoom-reset";
|
||||
const NOTE_ARCHIVE: &str = "note-archive";
|
||||
const NOTE_TRASH: &str = "note-trash";
|
||||
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
|
||||
const VIEW_GO_BACK: &str = "view-go-back";
|
||||
const VIEW_GO_FORWARD: &str = "view-go-forward";
|
||||
|
||||
const CUSTOM_IDS: &[&str] = &[
|
||||
APP_SETTINGS,
|
||||
FILE_NEW_NOTE,
|
||||
FILE_DAILY_NOTE,
|
||||
FILE_QUICK_OPEN,
|
||||
FILE_SAVE,
|
||||
FILE_CLOSE_TAB,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
EDIT_FIND_IN_VAULT,
|
||||
VIEW_EDITOR_ONLY,
|
||||
VIEW_EDITOR_LIST,
|
||||
VIEW_ALL,
|
||||
VIEW_TOGGLE_INSPECTOR,
|
||||
VIEW_COMMAND_PALETTE,
|
||||
VIEW_ZOOM_IN,
|
||||
VIEW_ZOOM_OUT,
|
||||
VIEW_ZOOM_RESET,
|
||||
VIEW_GO_BACK,
|
||||
VIEW_GO_FORWARD,
|
||||
];
|
||||
|
||||
/// IDs of menu items that should be disabled when no note tab is active.
|
||||
const NOTE_DEPENDENT_IDS: &[&str] = &[FILE_SAVE, FILE_CLOSE_TAB];
|
||||
const NOTE_DEPENDENT_IDS: &[&str] = &[FILE_SAVE, FILE_CLOSE_TAB, NOTE_ARCHIVE, NOTE_TRASH];
|
||||
|
||||
type MenuResult = Result<Submenu<tauri::Wry>, Box<dyn std::error::Error>>;
|
||||
|
||||
@@ -59,6 +77,10 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
.id(FILE_NEW_NOTE)
|
||||
.accelerator("CmdOrCtrl+N")
|
||||
.build(app)?;
|
||||
let daily_note = MenuItemBuilder::new("Open Today's Note")
|
||||
.id(FILE_DAILY_NOTE)
|
||||
.accelerator("CmdOrCtrl+J")
|
||||
.build(app)?;
|
||||
let quick_open = MenuItemBuilder::new("Quick Open")
|
||||
.id(FILE_QUICK_OPEN)
|
||||
.accelerator("CmdOrCtrl+P")
|
||||
@@ -71,18 +93,35 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
.id(FILE_CLOSE_TAB)
|
||||
.accelerator("CmdOrCtrl+W")
|
||||
.build(app)?;
|
||||
let archive_note = MenuItemBuilder::new("Archive Note")
|
||||
.id(NOTE_ARCHIVE)
|
||||
.accelerator("CmdOrCtrl+E")
|
||||
.build(app)?;
|
||||
let trash_note = MenuItemBuilder::new("Trash Note")
|
||||
.id(NOTE_TRASH)
|
||||
.accelerator("CmdOrCtrl+Backspace")
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "File")
|
||||
.item(&new_note)
|
||||
.item(&daily_note)
|
||||
.item(&quick_open)
|
||||
.separator()
|
||||
.item(&save)
|
||||
.separator()
|
||||
.item(&archive_note)
|
||||
.item(&trash_note)
|
||||
.separator()
|
||||
.item(&close_tab)
|
||||
.build()?)
|
||||
}
|
||||
|
||||
fn build_edit_menu(app: &App) -> MenuResult {
|
||||
let find_in_vault = MenuItemBuilder::new("Find in Vault")
|
||||
.id(EDIT_FIND_IN_VAULT)
|
||||
.accelerator("CmdOrCtrl+Shift+F")
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Edit")
|
||||
.undo()
|
||||
.redo()
|
||||
@@ -92,6 +131,8 @@ fn build_edit_menu(app: &App) -> MenuResult {
|
||||
.paste()
|
||||
.separator()
|
||||
.select_all()
|
||||
.separator()
|
||||
.item(&find_in_vault)
|
||||
.build()?)
|
||||
}
|
||||
|
||||
@@ -115,6 +156,26 @@ fn build_view_menu(app: &App) -> MenuResult {
|
||||
.id(VIEW_COMMAND_PALETTE)
|
||||
.accelerator("CmdOrCtrl+K")
|
||||
.build(app)?;
|
||||
let zoom_in = MenuItemBuilder::new("Zoom In")
|
||||
.id(VIEW_ZOOM_IN)
|
||||
.accelerator("CmdOrCtrl+=")
|
||||
.build(app)?;
|
||||
let zoom_out = MenuItemBuilder::new("Zoom Out")
|
||||
.id(VIEW_ZOOM_OUT)
|
||||
.accelerator("CmdOrCtrl+-")
|
||||
.build(app)?;
|
||||
let zoom_reset = MenuItemBuilder::new("Actual Size")
|
||||
.id(VIEW_ZOOM_RESET)
|
||||
.accelerator("CmdOrCtrl+0")
|
||||
.build(app)?;
|
||||
let go_back = MenuItemBuilder::new("Go Back")
|
||||
.id(VIEW_GO_BACK)
|
||||
.accelerator("CmdOrCtrl+[")
|
||||
.build(app)?;
|
||||
let go_forward = MenuItemBuilder::new("Go Forward")
|
||||
.id(VIEW_GO_FORWARD)
|
||||
.accelerator("CmdOrCtrl+]")
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "View")
|
||||
.item(&editor_only)
|
||||
@@ -123,6 +184,13 @@ fn build_view_menu(app: &App) -> MenuResult {
|
||||
.separator()
|
||||
.item(&toggle_inspector)
|
||||
.separator()
|
||||
.item(&go_back)
|
||||
.item(&go_forward)
|
||||
.separator()
|
||||
.item(&zoom_in)
|
||||
.item(&zoom_out)
|
||||
.item(&zoom_reset)
|
||||
.separator()
|
||||
.item(&command_palette)
|
||||
.build()?)
|
||||
}
|
||||
@@ -184,14 +252,23 @@ mod tests {
|
||||
let expected = [
|
||||
"app-settings",
|
||||
"file-new-note",
|
||||
"file-daily-note",
|
||||
"file-quick-open",
|
||||
"file-save",
|
||||
"file-close-tab",
|
||||
"note-archive",
|
||||
"note-trash",
|
||||
"edit-find-in-vault",
|
||||
"view-editor-only",
|
||||
"view-editor-list",
|
||||
"view-all",
|
||||
"view-toggle-inspector",
|
||||
"view-command-palette",
|
||||
"view-zoom-in",
|
||||
"view-zoom-out",
|
||||
"view-zoom-reset",
|
||||
"view-go-back",
|
||||
"view-go-forward",
|
||||
];
|
||||
for id in &expected {
|
||||
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");
|
||||
|
||||
@@ -255,4 +255,17 @@ mod tests {
|
||||
name
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn test_qmd_uri_fallback() {
|
||||
// Covers fallback branch when URI doesn't start with "qmd://"
|
||||
let result = qmd_uri_to_vault_path("invalid-uri", "/vault");
|
||||
assert!(result.contains("invalid-uri"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_clean_snippet_no_header() {
|
||||
// No @@ header — content_start = 0
|
||||
let snippet = extract_clean_snippet("plain content line");
|
||||
assert_eq!(snippet, "plain content line");
|
||||
}
|
||||
}
|
||||
|
||||
465
src-tauri/src/theme.rs
Normal file
@@ -0,0 +1,465 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// 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.
|
||||
pub fn set_active_theme(vault_path: &str, theme_id: &str) -> Result<(), String> {
|
||||
let mut settings = get_vault_settings(vault_path)?;
|
||||
settings.theme = Some(theme_id.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)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let themes_dir = Path::new(vault_path).join("_themes");
|
||||
if themes_dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
if fs::create_dir_all(&themes_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let _ = fs::write(themes_dir.join("default.json"), DEFAULT_THEME);
|
||||
let _ = fs::write(themes_dir.join("dark.json"), DARK_THEME);
|
||||
let _ = fs::write(themes_dir.join("minimal.json"), MINIMAL_THEME);
|
||||
log::info!("Seeded _themes/ with built-in themes");
|
||||
}
|
||||
|
||||
/// 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_id(&themes_dir, "untitled");
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// Find a filename that doesn't conflict (untitled, untitled-2, untitled-3, ...).
|
||||
fn find_available_id(dir: &Path, base: &str) -> String {
|
||||
if !dir.join(format!("{base}.json")).exists() {
|
||||
return base.to_string();
|
||||
}
|
||||
for i in 2.. {
|
||||
let candidate = format!("{base}-{i}");
|
||||
if !dir.join(format!("{candidate}.json")).exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// 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"
|
||||
}
|
||||
}"##;
|
||||
|
||||
#[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();
|
||||
|
||||
// Default settings have no theme
|
||||
let settings = get_vault_settings(vp).unwrap();
|
||||
assert!(settings.theme.is_none());
|
||||
|
||||
// Set and read back
|
||||
set_active_theme(vp, "dark").unwrap();
|
||||
let settings = get_vault_settings(vp).unwrap();
|
||||
assert_eq!(settings.theme.as_deref(), Some("dark"));
|
||||
}
|
||||
|
||||
#[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_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_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); // only default and dark
|
||||
}
|
||||
|
||||
#[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); // broken.json is skipped
|
||||
}
|
||||
}
|
||||
610
src-tauri/src/vault/getting_started.rs
Normal file
@@ -0,0 +1,610 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Default location for the Getting Started vault.
|
||||
pub fn default_vault_path() -> Result<PathBuf, String> {
|
||||
dirs::document_dir()
|
||||
.map(|d| d.join("Laputa"))
|
||||
.ok_or_else(|| "Could not determine Documents directory".to_string())
|
||||
}
|
||||
|
||||
/// Check whether a vault path exists on disk.
|
||||
pub fn vault_exists(path: &str) -> bool {
|
||||
Path::new(path).is_dir()
|
||||
}
|
||||
|
||||
struct SampleFile {
|
||||
rel_path: &'static str,
|
||||
content: &'static str,
|
||||
}
|
||||
|
||||
/// Content for the AGENTS.md file written to the vault root.
|
||||
/// This file has no YAML frontmatter — it is a convention file for AI agents,
|
||||
/// not a vault note. The vault scanner will still pick it up as a regular entry.
|
||||
const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents
|
||||
|
||||
This is a [Laputa](https://github.com/refactoring-ai/laputa) vault — a folder of markdown files with YAML frontmatter that form a personal knowledge graph.
|
||||
|
||||
## Structure
|
||||
|
||||
Files are organized in folders by type:
|
||||
|
||||
| Folder | Type | Purpose |
|
||||
|--------|------|---------|
|
||||
| `note/` | Note | General-purpose documents, research, meeting notes |
|
||||
| `project/` | Project | Time-bounded efforts with clear goals |
|
||||
| `person/` | Person | People — colleagues, collaborators, contacts |
|
||||
| `topic/` | Topic | Subject areas that group related notes |
|
||||
| `responsibility/` | Responsibility | Long-running duties with KPIs |
|
||||
| `procedure/` | Procedure | Recurring workflows (weekly, monthly) |
|
||||
| `event/` | Event | Something that happened on a specific date |
|
||||
| `quarter/` | Quarter | Time containers (e.g. 24Q1) |
|
||||
| `measure/` | Measure | Trackable metrics tied to responsibilities |
|
||||
| `target/` | Target | Time-bound goals for a measure |
|
||||
| `type/` | Type | Type definitions — icon, color, ordering |
|
||||
|
||||
Custom folders are valid — the folder name becomes the type (capitalized).
|
||||
|
||||
## Frontmatter
|
||||
|
||||
YAML frontmatter between `---` delimiters defines metadata:
|
||||
|
||||
```yaml
|
||||
---
|
||||
Is A: Project
|
||||
Status: Active
|
||||
Owner: "[[person/jane-doe]]"
|
||||
Belongs to: "[[quarter/24q1]]"
|
||||
Related to:
|
||||
- "[[topic/growth]]"
|
||||
- "[[note/research-findings]]"
|
||||
---
|
||||
```
|
||||
|
||||
### Standard fields
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `Is A` | Entity type (usually inferred from folder) |
|
||||
| `Status` | Active, Done, Paused, Archived, Dropped |
|
||||
| `Owner` | Person responsible (wikilink) |
|
||||
| `Belongs to` | Parent relationship(s) |
|
||||
| `Related to` | Lateral associations |
|
||||
| `Cadence` | For Procedures: Weekly, Monthly, etc. |
|
||||
| `aliases` | Alternative names for wikilink resolution |
|
||||
|
||||
### Custom fields
|
||||
|
||||
Any YAML field containing `[[wikilinks]]` becomes a navigable relationship:
|
||||
|
||||
```yaml
|
||||
Has Measures: ["[[measure/revenue]]", "[[measure/churn]]"]
|
||||
Resources: "[[note/api-docs]]"
|
||||
```
|
||||
|
||||
## Wikilinks
|
||||
|
||||
Connect notes with double-bracket syntax:
|
||||
|
||||
- `[[note/my-note]]` — link by path
|
||||
- `[[My Note Title]]` — link by title or alias
|
||||
- `[[note/my-note|display text]]` — link with custom display text
|
||||
|
||||
Wikilinks work in both frontmatter values and markdown body. Backlinks are computed automatically — linking A to B makes B show a backlink to A.
|
||||
|
||||
## Type definitions
|
||||
|
||||
Files in `type/` define entity types and control how they appear in the sidebar:
|
||||
|
||||
```yaml
|
||||
---
|
||||
Is A: Type
|
||||
icon: rocket-launch
|
||||
color: purple
|
||||
order: 1
|
||||
---
|
||||
```
|
||||
|
||||
Available colors: red, purple, blue, green, yellow, orange. Icons are Phosphor names in kebab-case.
|
||||
|
||||
## Conventions
|
||||
|
||||
- First `# Heading` in a file becomes its title
|
||||
- One entity per file
|
||||
- Filenames use kebab-case: `my-note-title.md`
|
||||
- Type is inferred from parent folder if not set in frontmatter
|
||||
- Relationships are bidirectional via automatic backlinks
|
||||
"#;
|
||||
|
||||
const SAMPLE_FILES: &[SampleFile] = &[
|
||||
SampleFile {
|
||||
rel_path: "type/project.md",
|
||||
content: "---\nIs A: Type\nicon: rocket-launch\ncolor: purple\norder: 1\n---\n\n# Project\n\nA Project is a time-bounded effort with a clear goal and an eventual completion date. Projects belong to a quarter or area and advance specific goals.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/note.md",
|
||||
content: "---\nIs A: Type\nicon: note\ncolor: blue\norder: 2\n---\n\n# Note\n\nA Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/person.md",
|
||||
content: "---\nIs A: Type\nicon: user\ncolor: green\norder: 3\n---\n\n# Person\n\nA Person represents someone you interact with — a colleague, friend, mentor, or collaborator.\n",
|
||||
},
|
||||
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: "note/welcome-to-laputa.md",
|
||||
content: r#"---
|
||||
Is A: Note
|
||||
Related to:
|
||||
- "[[note/editor-basics]]"
|
||||
- "[[note/using-properties]]"
|
||||
- "[[note/wiki-links-and-relationships]]"
|
||||
---
|
||||
|
||||
# Welcome to Laputa
|
||||
|
||||
Welcome to your new knowledge vault! Laputa helps you organize your thoughts, projects, and relationships using **wiki-linked markdown files**.
|
||||
|
||||
## How it works
|
||||
|
||||
Every note is a markdown file with optional YAML frontmatter at the top. Notes live in folders that define their **type** — a file in the `project/` folder is automatically a Project, a file in `person/` is a Person, and so on.
|
||||
|
||||
## What to explore
|
||||
|
||||
- [[note/editor-basics]] — Learn about headings, lists, checkboxes, and formatting
|
||||
- [[note/using-properties]] — See how frontmatter properties work (status, dates, relationships)
|
||||
- [[note/wiki-links-and-relationships]] — Connect your notes with `[[wiki-links]]`
|
||||
- [[project/sample-project]] — A sample project with relationships and status
|
||||
- [[person/sample-collaborator]] — A sample person entry
|
||||
|
||||
## Tips
|
||||
|
||||
- Press **⌘P** to quick-open any note by title
|
||||
- Press **⌘K** to open the command palette
|
||||
- Press **⌘N** to create a new note
|
||||
- Use the **sidebar** on the left to browse by type
|
||||
- Use the **inspector** on the right to edit properties and see backlinks
|
||||
"#,
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "note/editor-basics.md",
|
||||
content: r#"---
|
||||
Is A: Note
|
||||
Related to: "[[note/welcome-to-laputa]]"
|
||||
---
|
||||
|
||||
# Editor Basics
|
||||
|
||||
Laputa uses a rich markdown editor. Here are the key formatting features:
|
||||
|
||||
## Headings
|
||||
|
||||
Use `#` for headings. The first H1 heading becomes the note's title.
|
||||
|
||||
## Lists
|
||||
|
||||
- Bullet lists use `-` or `*`
|
||||
- They can be nested
|
||||
- Like this
|
||||
- And this
|
||||
|
||||
1. Numbered lists work too
|
||||
2. Just start with a number
|
||||
|
||||
## Checkboxes
|
||||
|
||||
- [x] Completed task
|
||||
- [ ] Pending task
|
||||
- [ ] Another thing to do
|
||||
|
||||
## Text formatting
|
||||
|
||||
You can use **bold**, *italic*, `inline code`, and ~~strikethrough~~ text.
|
||||
|
||||
## Code blocks
|
||||
|
||||
```javascript
|
||||
function hello() {
|
||||
console.log("Hello from Laputa!");
|
||||
}
|
||||
```
|
||||
|
||||
## Blockquotes
|
||||
|
||||
> "The best way to have a good idea is to have lots of ideas." — Linus Pauling
|
||||
"#,
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "note/using-properties.md",
|
||||
content: r#"---
|
||||
Is A: Note
|
||||
Status: Active
|
||||
Related to:
|
||||
- "[[note/welcome-to-laputa]]"
|
||||
- "[[note/wiki-links-and-relationships]]"
|
||||
---
|
||||
|
||||
# Using Properties
|
||||
|
||||
Every note can have **properties** defined in the YAML frontmatter at the top of the file. Properties appear in the inspector panel on the right side of the screen.
|
||||
|
||||
## Common properties
|
||||
|
||||
- **Is A** — The note's type (Project, Note, Person, etc.)
|
||||
- **Status** — Current state: Active, Done, Paused, Archived, Dropped
|
||||
- **Belongs to** — Parent relationship (e.g., a project belongs to a quarter)
|
||||
- **Related to** — Lateral connections to other notes
|
||||
- **Owner** — The person responsible
|
||||
|
||||
## How to edit properties
|
||||
|
||||
1. Open the **inspector panel** (right side)
|
||||
2. Click on any property value to edit it
|
||||
3. For relationship fields, type `[[` to search for notes
|
||||
4. Use the **+ Add property** button to add custom fields
|
||||
|
||||
## Custom properties
|
||||
|
||||
You can add any custom property. If the value contains `[[wiki-links]]`, Laputa will treat it as a relationship and show it as a clickable link in the inspector.
|
||||
"#,
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "note/wiki-links-and-relationships.md",
|
||||
content: r#"---
|
||||
Is A: Note
|
||||
Related to:
|
||||
- "[[note/welcome-to-laputa]]"
|
||||
- "[[note/using-properties]]"
|
||||
---
|
||||
|
||||
# Wiki-Links and Relationships
|
||||
|
||||
Wiki-links are the core of Laputa's knowledge graph. They let you connect any note to any other note using the `[[double bracket]]` syntax.
|
||||
|
||||
## Creating links
|
||||
|
||||
Type `[[` in the editor to open the link suggestion menu. Start typing to search for a note, then select it. The link will look like this: [[note/welcome-to-laputa]].
|
||||
|
||||
## Backlinks
|
||||
|
||||
When note A links to note B, note B automatically shows a **backlink** to note A in the inspector panel. This means you never have to manually maintain bidirectional links.
|
||||
|
||||
## Relationships in frontmatter
|
||||
|
||||
You can also define relationships in the frontmatter:
|
||||
|
||||
```yaml
|
||||
Belongs to: "[[project/sample-project]]"
|
||||
Related to:
|
||||
- "[[note/editor-basics]]"
|
||||
- "[[note/using-properties]]"
|
||||
```
|
||||
|
||||
These appear as clickable pills in the inspector and are navigable with a single click.
|
||||
|
||||
## Building your knowledge graph
|
||||
|
||||
Over time, your wiki-links form a rich web of connections. Use the **Referenced By** section in the inspector to discover how notes relate to each other.
|
||||
"#,
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "project/sample-project.md",
|
||||
content: r#"---
|
||||
Is A: Project
|
||||
Status: Active
|
||||
Owner: "[[person/sample-collaborator]]"
|
||||
Related to: "[[topic/getting-started]]"
|
||||
---
|
||||
|
||||
# Sample Project
|
||||
|
||||
This is an example project to show how projects work in Laputa.
|
||||
|
||||
## Overview
|
||||
|
||||
Projects are time-bounded efforts with clear goals. They have a **status** (Active, Paused, Done, Dropped) and can be linked to people, topics, and other notes.
|
||||
|
||||
## Goals
|
||||
|
||||
- [ ] Explore the Laputa editor and its features
|
||||
- [ ] Create your first custom note
|
||||
- [ ] Link notes together using wiki-links
|
||||
- [ ] Try editing properties in the inspector
|
||||
|
||||
## Notes
|
||||
|
||||
This project is owned by [[person/sample-collaborator]] and relates to [[topic/getting-started]]. You can see these relationships in the inspector panel on the right.
|
||||
"#,
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "person/sample-collaborator.md",
|
||||
content: r#"---
|
||||
Is A: Person
|
||||
---
|
||||
|
||||
# Sample Collaborator
|
||||
|
||||
This is an example person entry. In your vault, you might create entries for colleagues, friends, mentors, or anyone you interact with regularly.
|
||||
|
||||
## What person entries are for
|
||||
|
||||
- Track who owns which projects
|
||||
- Record meeting notes linked to specific people
|
||||
- Build a network of relationships between people, projects, and topics
|
||||
|
||||
## Connections
|
||||
|
||||
This person is the owner of [[project/sample-project]]. Check the **Referenced By** section in the inspector to see all notes that link back here.
|
||||
"#,
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "topic/getting-started.md",
|
||||
content: r#"---
|
||||
Is A: Topic
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
|
||||
This topic groups notes related to learning and getting started with Laputa.
|
||||
|
||||
## Related notes
|
||||
|
||||
- [[note/welcome-to-laputa]] — Start here for an overview
|
||||
- [[note/editor-basics]] — Formatting and editor features
|
||||
- [[note/using-properties]] — Frontmatter and the inspector
|
||||
- [[note/wiki-links-and-relationships]] — Building your knowledge graph
|
||||
- [[project/sample-project]] — A sample project with relationships
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
/// Create the Getting Started vault at the specified path.
|
||||
/// Returns the absolute path to the created vault.
|
||||
pub fn create_getting_started_vault(target_path: &str) -> Result<String, String> {
|
||||
let vault_dir = Path::new(target_path);
|
||||
|
||||
if vault_dir.exists()
|
||||
&& vault_dir
|
||||
.read_dir()
|
||||
.map(|mut d| d.next().is_some())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(format!(
|
||||
"Directory already exists and is not empty: {}",
|
||||
target_path
|
||||
));
|
||||
}
|
||||
|
||||
fs::create_dir_all(vault_dir)
|
||||
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
|
||||
|
||||
// Write AGENTS.md at the vault root
|
||||
fs::write(vault_dir.join("AGENTS.md"), AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md: {}", e))?;
|
||||
|
||||
for sample in SAMPLE_FILES {
|
||||
let file_path = vault_dir.join(sample.rel_path);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||
}
|
||||
fs::write(&file_path, sample.content)
|
||||
.map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?;
|
||||
}
|
||||
|
||||
// Seed built-in themes
|
||||
let themes_dir = vault_dir.join("_themes");
|
||||
fs::create_dir_all(&themes_dir)
|
||||
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
|
||||
fs::write(themes_dir.join("default.json"), crate::theme::DEFAULT_THEME)
|
||||
.map_err(|e| format!("Failed to write default theme: {e}"))?;
|
||||
fs::write(themes_dir.join("dark.json"), crate::theme::DARK_THEME)
|
||||
.map_err(|e| format!("Failed to write dark theme: {e}"))?;
|
||||
fs::write(themes_dir.join("minimal.json"), crate::theme::MINIMAL_THEME)
|
||||
.map_err(|e| format!("Failed to write minimal theme: {e}"))?;
|
||||
|
||||
crate::git::init_repo(target_path)?;
|
||||
|
||||
Ok(vault_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| vault_dir.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_vault_path_is_in_documents() {
|
||||
let path = default_vault_path().unwrap();
|
||||
let path_str = path.to_string_lossy();
|
||||
assert!(path_str.contains("Documents"));
|
||||
assert!(path_str.ends_with("Laputa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_exists_false_for_missing() {
|
||||
assert!(!vault_exists("/nonexistent/vault/path/abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_creates_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("test-vault");
|
||||
let result = create_getting_started_vault(vault_path.to_str().unwrap());
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify key files exist
|
||||
assert!(vault_path.join("AGENTS.md").exists());
|
||||
assert!(vault_path.join("note/welcome-to-laputa.md").exists());
|
||||
assert!(vault_path.join("note/editor-basics.md").exists());
|
||||
assert!(vault_path.join("note/using-properties.md").exists());
|
||||
assert!(vault_path
|
||||
.join("note/wiki-links-and-relationships.md")
|
||||
.exists());
|
||||
assert!(vault_path.join("project/sample-project.md").exists());
|
||||
assert!(vault_path.join("person/sample-collaborator.md").exists());
|
||||
assert!(vault_path.join("topic/getting-started.md").exists());
|
||||
assert!(vault_path.join("type/project.md").exists());
|
||||
assert!(vault_path.join("type/note.md").exists());
|
||||
assert!(vault_path.join("type/person.md").exists());
|
||||
assert!(vault_path.join("type/topic.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_rejects_non_empty_directory() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("non-empty");
|
||||
fs::create_dir_all(&vault_path).unwrap();
|
||||
fs::write(vault_path.join("existing.md"), "# Existing").unwrap();
|
||||
|
||||
let result = create_getting_started_vault(vault_path.to_str().unwrap());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_allows_empty_directory() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("empty-dir");
|
||||
fs::create_dir_all(&vault_path).unwrap();
|
||||
|
||||
let result = create_getting_started_vault(vault_path.to_str().unwrap());
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_files_have_valid_frontmatter() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("validation-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
for sample in SAMPLE_FILES {
|
||||
let file_path = vault_path.join(sample.rel_path);
|
||||
let content = fs::read_to_string(&file_path).unwrap();
|
||||
// Verify each file has frontmatter delimiters
|
||||
assert!(
|
||||
content.starts_with("---\n"),
|
||||
"{} should start with frontmatter",
|
||||
sample.rel_path
|
||||
);
|
||||
assert!(
|
||||
content.matches("---").count() >= 2,
|
||||
"{} should have closing frontmatter delimiter",
|
||||
sample.rel_path
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_files_parseable_as_vault_entries() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("parse-vault");
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_md_present_after_vault_creation() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("agents-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let agents_path = vault_path.join("AGENTS.md");
|
||||
assert!(agents_path.exists(), "AGENTS.md should exist at vault root");
|
||||
|
||||
let content = fs::read_to_string(&agents_path).unwrap();
|
||||
assert!(
|
||||
content.contains("Vault Instructions for AI Agents"),
|
||||
"AGENTS.md should contain instructions header"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Structure"),
|
||||
"AGENTS.md should describe vault structure"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Frontmatter"),
|
||||
"AGENTS.md should describe frontmatter"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Wikilinks"),
|
||||
"AGENTS.md should describe wikilinks"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Type definitions"),
|
||||
"AGENTS.md should describe type definitions"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Conventions"),
|
||||
"AGENTS.md should describe conventions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_md_parseable_as_vault_entry() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("agents-parse-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md")).unwrap();
|
||||
assert_eq!(
|
||||
entry.title,
|
||||
"AGENTS.md \u{2014} Vault Instructions for AI Agents"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_initializes_git() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("git-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(vault_path.join(".git").exists());
|
||||
|
||||
let log = std::process::Command::new("git")
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(&vault_path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Initial vault setup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_seeds_themes() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("theme-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_no_untracked_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("clean-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let status = std::process::Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&vault_path)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
|
||||
"All files should be committed, no untracked files"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,24 +14,29 @@ fn sanitize_filename(name: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Save an uploaded image to the vault's attachments directory.
|
||||
/// Returns the absolute path to the saved file.
|
||||
pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result<String, String> {
|
||||
use base64::Engine;
|
||||
|
||||
let vault = Path::new(vault_path);
|
||||
let attachments_dir = vault.join("attachments");
|
||||
/// Image file extensions considered valid for drag-drop import.
|
||||
const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"];
|
||||
|
||||
/// Prepare the attachments directory and generate a unique target path.
|
||||
fn prepare_attachment_path(vault_path: &str, filename: &str) -> Result<std::path::PathBuf, String> {
|
||||
let attachments_dir = Path::new(vault_path).join("attachments");
|
||||
fs::create_dir_all(&attachments_dir)
|
||||
.map_err(|e| format!("Failed to create attachments directory: {}", e))?;
|
||||
|
||||
// Generate unique filename to avoid collisions
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
let unique_name = format!("{}-{}", timestamp, sanitize_filename(filename));
|
||||
let target_path = attachments_dir.join(&unique_name);
|
||||
Ok(attachments_dir.join(unique_name))
|
||||
}
|
||||
|
||||
/// Save an uploaded image to the vault's attachments directory.
|
||||
/// Returns the absolute path to the saved file.
|
||||
pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result<String, String> {
|
||||
use base64::Engine;
|
||||
|
||||
let target_path = prepare_attachment_path(vault_path, filename)?;
|
||||
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(data)
|
||||
@@ -42,6 +47,35 @@ pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result<String
|
||||
Ok(target_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Copy an image file from a source path into the vault's attachments directory.
|
||||
/// Used for Tauri native drag-drop which provides absolute file paths.
|
||||
/// Returns the absolute path to the saved file.
|
||||
pub fn copy_image_to_vault(vault_path: &str, source_path: &str) -> Result<String, String> {
|
||||
let source = Path::new(source_path);
|
||||
if !source.exists() {
|
||||
return Err(format!("Source file does not exist: {}", source_path));
|
||||
}
|
||||
|
||||
let ext = source
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
if !IMAGE_EXTENSIONS.contains(&ext.as_str()) {
|
||||
return Err(format!("Not a supported image format: {}", source_path));
|
||||
}
|
||||
|
||||
let filename = source
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("image");
|
||||
let target_path = prepare_attachment_path(vault_path, filename)?;
|
||||
|
||||
fs::copy(source, &target_path).map_err(|e| format!("Failed to copy image: {}", e))?;
|
||||
|
||||
Ok(target_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -102,4 +136,61 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid base64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_image_to_vault_success() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
// Create a source image file
|
||||
let source_path = dir.path().join("source.png");
|
||||
fs::write(&source_path, b"fake png data").unwrap();
|
||||
|
||||
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
|
||||
assert!(result.is_ok());
|
||||
|
||||
let saved_path = result.unwrap();
|
||||
assert!(std::path::Path::new(&saved_path).exists());
|
||||
assert!(saved_path.contains("attachments"));
|
||||
assert!(saved_path.contains("source.png"));
|
||||
|
||||
let content = fs::read(&saved_path).unwrap();
|
||||
assert_eq!(content, b"fake png data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_image_to_vault_nonexistent_source() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let result = copy_image_to_vault(vault_path, "/nonexistent/photo.png");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_image_to_vault_rejects_non_image() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let source_path = dir.path().join("document.pdf");
|
||||
fs::write(&source_path, b"fake pdf").unwrap();
|
||||
|
||||
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Not a supported image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_image_to_vault_accepts_all_extensions() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
for ext in &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"] {
|
||||
let source_path = dir.path().join(format!("img.{}", ext));
|
||||
fs::write(&source_path, b"data").unwrap();
|
||||
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
|
||||
assert!(result.is_ok(), "failed for extension: {}", ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod cache;
|
||||
mod getting_started;
|
||||
mod image;
|
||||
mod migration;
|
||||
mod parsing;
|
||||
@@ -6,7 +7,8 @@ mod rename;
|
||||
mod trash;
|
||||
|
||||
pub use cache::scan_vault_cached;
|
||||
pub use image::save_image;
|
||||
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{rename_note, RenameResult};
|
||||
pub use trash::purge_trash;
|
||||
@@ -60,6 +62,12 @@ pub struct VaultEntry {
|
||||
pub color: Option<String>,
|
||||
/// Display order for Type entries in sidebar (lower = higher). None = use default order.
|
||||
pub order: Option<i64>,
|
||||
/// Custom sidebar section label for Type entries, overriding auto-pluralization.
|
||||
#[serde(rename = "sidebarLabel")]
|
||||
pub sidebar_label: Option<String>,
|
||||
/// Markdown template for notes of this Type. When a new note is created
|
||||
/// with this type, the template body is pre-filled after the frontmatter.
|
||||
pub template: Option<String>,
|
||||
/// Word count of the note body (excludes frontmatter and H1 title).
|
||||
#[serde(rename = "wordCount")]
|
||||
pub word_count: u32,
|
||||
@@ -72,7 +80,7 @@ pub struct VaultEntry {
|
||||
/// Intermediate struct to capture YAML frontmatter fields.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct Frontmatter {
|
||||
#[serde(rename = "Is A")]
|
||||
#[serde(rename = "Is A", alias = "type")]
|
||||
is_a: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
aliases: Option<StringOrList>,
|
||||
@@ -102,6 +110,10 @@ struct Frontmatter {
|
||||
color: Option<String>,
|
||||
#[serde(default)]
|
||||
order: Option<i64>,
|
||||
#[serde(rename = "sidebar label", default)]
|
||||
sidebar_label: Option<String>,
|
||||
#[serde(default)]
|
||||
template: Option<String>,
|
||||
}
|
||||
|
||||
/// Handles YAML fields that can be either a single string or a list of strings.
|
||||
@@ -133,6 +145,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
/// Only skip keys that can never contain wikilinks.
|
||||
const SKIP_KEYS: &[&str] = &[
|
||||
"is a",
|
||||
"type",
|
||||
"aliases",
|
||||
"status",
|
||||
"cadence",
|
||||
@@ -144,6 +157,8 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
"sidebar label",
|
||||
"template",
|
||||
];
|
||||
|
||||
/// Extract all wikilink-containing fields from raw YAML frontmatter.
|
||||
@@ -322,6 +337,8 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
icon: frontmatter.icon,
|
||||
color: frontmatter.color,
|
||||
order: frontmatter.order,
|
||||
sidebar_label: frontmatter.sidebar_label,
|
||||
template: frontmatter.template,
|
||||
word_count,
|
||||
outgoing_links,
|
||||
})
|
||||
@@ -966,6 +983,39 @@ References:
|
||||
assert_eq!(entry.is_a, Some("Type".to_string()));
|
||||
}
|
||||
|
||||
// --- type key (post-migration) tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_type_key_lowercase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Project\n---\n# My Project\n";
|
||||
let entry = parse_test_entry(&dir, "project/my-project.md", content);
|
||||
assert_eq!(entry.is_a, Some("Project".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_key_generates_type_relationship() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Person\n---\n# Alice\n";
|
||||
let entry = parse_test_entry(&dir, "person/alice.md", content);
|
||||
assert_eq!(
|
||||
entry.relationships.get("Type").unwrap(),
|
||||
&vec!["[[type/person]]".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_key_not_in_relationships_as_generic() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Note\nHas:\n - \"[[task/foo]]\"\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "note/test.md", content);
|
||||
// "type" key itself should not appear as a relationship (it's in SKIP_KEYS)
|
||||
// Only "Has" and the auto-generated "Type" should be relationships
|
||||
assert_eq!(entry.relationships.len(), 2);
|
||||
assert!(entry.relationships.get("Has").is_some());
|
||||
assert!(entry.relationships.get("Type").is_some());
|
||||
}
|
||||
|
||||
// --- outgoing_links tests ---
|
||||
|
||||
#[test]
|
||||
@@ -1019,6 +1069,71 @@ References:
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
// --- sidebar_label tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidebar_label_from_type_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsidebar label: News\n---\n# News\n";
|
||||
let entry = parse_test_entry(&dir, "type/news.md", content);
|
||||
assert_eq!(entry.sidebar_label, Some("News".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidebar_label_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.sidebar_label, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sidebar_label_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsidebar label: My Series\n---\n# Series\n";
|
||||
let entry = parse_test_entry(&dir, "type/series.md", content);
|
||||
assert!(entry.relationships.get("sidebar label").is_none());
|
||||
}
|
||||
|
||||
// --- template field tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_from_type_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: \"## Objective\\n\\n## Timeline\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.template.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_block_scalar() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Objective\n \n ## Timeline\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.template.is_some());
|
||||
let tmpl = entry.template.unwrap();
|
||||
assert!(tmpl.contains("## Objective"));
|
||||
assert!(tmpl.contains("## Timeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_missing_defaults_to_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n# Note\n";
|
||||
let entry = parse_test_entry(&dir, "type/note.md", content);
|
||||
assert_eq!(entry.template, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\ntemplate: \"## Heading\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.relationships.get("template").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
|
||||
|
||||
@@ -48,9 +48,9 @@
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://github.com/refactoringhq/laputa-app/releases/latest/download/latest.json"
|
||||
"https://refactoringhq.github.io/laputa-app/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQxQjNDRUVGNTgxM0Q2RDQKUldUVTFoTlk3ODZ6MGNYSml6aWF4SzBvMTFvdDJFQWQwZHZ0d0lDeW9LekRHZ3h5MnJsb2lpenEK"
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRFQzlGQ0RFM0E1MTIzNDkKUldSSkkxRTYzdnpKVG13M0Zwd3M1RzErbWhJeEhBQUQyaG90bHBtMkNzMm1MNERZRlpXSGFRMTUK"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ const mockEntries = [
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -50,6 +51,7 @@ const mockEntries = [
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
@@ -59,30 +61,33 @@ const mockAllContent: Record<string, string> = {
|
||||
'/vault/topic/dev.md': '---\ntitle: Software Development\nis_a: Topic\n---\n\n# Software Development\n',
|
||||
}
|
||||
|
||||
const mockCommandResults: Record<string, unknown> = {
|
||||
list_vault: mockEntries,
|
||||
get_all_content: mockAllContent,
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
get_file_history: [],
|
||||
get_settings: { anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null },
|
||||
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
|
||||
save_settings: null,
|
||||
check_vault_exists: true,
|
||||
get_default_vault_path: '/Users/mock/Documents/Laputa',
|
||||
list_themes: [],
|
||||
get_vault_settings: { theme: null },
|
||||
}
|
||||
|
||||
vi.mock('./mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'list_vault') return mockEntries
|
||||
if (cmd === 'get_all_content') return mockAllContent
|
||||
if (cmd === 'get_modified_files') return []
|
||||
if (cmd === 'get_note_content') return mockAllContent['/vault/project/test.md'] || ''
|
||||
if (cmd === 'get_file_history') return []
|
||||
if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null }
|
||||
if (cmd === 'git_pull') return { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }
|
||||
if (cmd === 'save_settings') return null
|
||||
return null
|
||||
}),
|
||||
mockInvoke: vi.fn(async (cmd: string) => mockCommandResults[cmd] ?? null),
|
||||
addMockEntry: vi.fn(),
|
||||
updateMockContent: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock ai-chat utilities (uses localStorage which may not be available in jsdom)
|
||||
// Mock ai-chat utilities
|
||||
vi.mock('./utils/ai-chat', () => ({
|
||||
setApiKey: vi.fn(),
|
||||
getApiKey: vi.fn(() => ''),
|
||||
MODEL_OPTIONS: [{ value: 'claude-3-5-haiku-20241022', label: 'Haiku 3.5' }],
|
||||
buildSystemPrompt: vi.fn(() => ({ prompt: '', totalTokens: 0, truncated: false })),
|
||||
streamChat: vi.fn(),
|
||||
checkClaudeCli: vi.fn(async () => ({ installed: false })),
|
||||
streamClaudeChat: vi.fn(async () => 'mock-session'),
|
||||
}))
|
||||
|
||||
// Mock BlockNote components (they need DOM APIs not available in jsdom)
|
||||
@@ -120,8 +125,9 @@ describe('App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Reset view mode between tests so sidebar starts visible
|
||||
// Reset view mode and onboarding state between tests
|
||||
localStorage.removeItem('laputa-view-mode')
|
||||
localStorage.removeItem('laputa_welcome_dismissed')
|
||||
})
|
||||
|
||||
it('renders the four-panel layout', async () => {
|
||||
|
||||
114
src/App.tsx
@@ -12,6 +12,8 @@ import { CommitDialog } from './components/CommitDialog'
|
||||
import { StatusBar } from './components/StatusBar'
|
||||
import { SettingsPanel } from './components/SettingsPanel'
|
||||
import { GitHubVaultModal } from './components/GitHubVaultModal'
|
||||
import { WelcomeScreen } from './components/WelcomeScreen'
|
||||
import { useMcpRegistration } from './hooks/useMcpRegistration'
|
||||
import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteActions } from './hooks/useNoteActions'
|
||||
@@ -26,8 +28,11 @@ import { useGitHistory } from './hooks/useGitHistory'
|
||||
import { useUpdater } from './hooks/useUpdater'
|
||||
import { useNavigationHistory } from './hooks/useNavigationHistory'
|
||||
import { useAutoSync } from './hooks/useAutoSync'
|
||||
import { useZoom } from './hooks/useZoom'
|
||||
import { useBuildNumber } from './hooks/useBuildNumber'
|
||||
import { useOnboarding } from './hooks/useOnboarding'
|
||||
import { useThemeManager } from './hooks/useThemeManager'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { setApiKey } from './utils/ai-chat'
|
||||
import { extractOutgoingLinks } from './utils/wikilinks'
|
||||
import type { SidebarSelection } from './types'
|
||||
import './App.css'
|
||||
@@ -76,13 +81,14 @@ function useEditorSaveWithLinks(config: {
|
||||
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onAfterSave: () => void
|
||||
onNotePersisted?: (path: string) => void
|
||||
}) {
|
||||
const { updateContent, updateEntry } = config
|
||||
const saveContent = useCallback((path: string, content: string) => {
|
||||
updateContent(path, content)
|
||||
updateEntry(path, { outgoingLinks: extractOutgoingLinks(content) })
|
||||
}, [updateContent, updateEntry])
|
||||
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave })
|
||||
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted })
|
||||
const { handleContentChange: rawOnChange } = editor
|
||||
const prevLinksKeyRef = useRef('')
|
||||
const handleContentChange = useCallback((path: string, content: string) => {
|
||||
@@ -111,13 +117,18 @@ function App() {
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
|
||||
const vault = useVaultLoader(vaultSwitcher.vaultPath)
|
||||
const { settings, saveSettings } = useSettings()
|
||||
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
|
||||
|
||||
useEffect(() => { setApiKey(settings.anthropic_key ?? '') }, [settings.anthropic_key])
|
||||
// When onboarding resolves to a different vault path, update the switcher
|
||||
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
|
||||
const vault = useVaultLoader(resolvedPath)
|
||||
const { settings, saveSettings } = useSettings()
|
||||
const themeManager = useThemeManager(resolvedPath)
|
||||
|
||||
useMcpRegistration(resolvedPath, setToastMessage)
|
||||
|
||||
const autoSync = useAutoSync({
|
||||
vaultPath: vaultSwitcher.vaultPath,
|
||||
vaultPath: resolvedPath,
|
||||
intervalMinutes: settings.auto_pull_interval_minutes,
|
||||
onVaultUpdated: vault.reloadVault,
|
||||
onConflict: (files) => {
|
||||
@@ -127,7 +138,11 @@ function App() {
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave })
|
||||
// Ref bridges handleContentChange (created after notes) into useNoteActions.
|
||||
// Read at callback time, so it's always current when user presses Cmd+N.
|
||||
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
|
||||
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles })
|
||||
|
||||
const navHistory = useNavigationHistory()
|
||||
|
||||
@@ -198,10 +213,21 @@ function App() {
|
||||
}
|
||||
}, [handleGoBack, handleGoForward])
|
||||
|
||||
const { handleSave, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
|
||||
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
|
||||
updateContent: vault.updateContent, updateEntry: vault.updateEntry,
|
||||
setTabs: notes.setTabs, setToastMessage, onAfterSave: vault.loadModifiedFiles,
|
||||
onNotePersisted: vault.clearUnsaved,
|
||||
})
|
||||
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
|
||||
|
||||
// Wrap handleSave to also persist unsaved notes that have no pending edits (user pressed Cmd+S without typing)
|
||||
const handleSave = useCallback(async () => {
|
||||
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
|
||||
const fallback = activeTab && vault.unsavedPaths.has(activeTab.entry.path)
|
||||
? { path: activeTab.entry.path, content: activeTab.content }
|
||||
: undefined
|
||||
await handleSaveRaw(fallback)
|
||||
}, [handleSaveRaw, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
|
||||
|
||||
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage })
|
||||
|
||||
@@ -220,12 +246,22 @@ function App() {
|
||||
|
||||
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
||||
await savePendingForPath(path)
|
||||
await notes.handleRenameNote(path, newTitle, vaultSwitcher.vaultPath, vault.replaceEntry).then(vault.loadModifiedFiles)
|
||||
}, [notes, vaultSwitcher.vaultPath, vault, savePendingForPath])
|
||||
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
|
||||
}, [notes, resolvedPath, vault, savePendingForPath])
|
||||
|
||||
/** H1→title sync: update VaultEntry.title and tab entry in memory. */
|
||||
const handleTitleSync = useCallback((path: string, newTitle: string) => {
|
||||
vault.updateEntry(path, { title: newTitle })
|
||||
notes.setTabs(prev => prev.map(t =>
|
||||
t.entry.path === path ? { ...t, entry: { ...t.entry, title: newTitle } } : t
|
||||
))
|
||||
}, [vault, notes])
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
|
||||
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
|
||||
const zoom = useZoom()
|
||||
const buildNumber = useBuildNumber()
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
@@ -234,31 +270,69 @@ function App() {
|
||||
modifiedCount: vault.modifiedFiles.length, selection,
|
||||
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
|
||||
onSearch: dialogs.openSearch,
|
||||
onCreateNote: notes.handleCreateNoteImmediate, onSave: handleSave,
|
||||
onCreateNote: notes.handleCreateNoteImmediate,
|
||||
onOpenDailyNote: notes.handleOpenDailyNote,
|
||||
onCreateNoteOfType: notes.handleCreateNoteImmediate,
|
||||
onSave: handleSave,
|
||||
onOpenSettings: dialogs.openSettings,
|
||||
onTrashNote: entryActions.handleTrashNote, onArchiveNote: entryActions.handleArchiveNote,
|
||||
onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
|
||||
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
zoomLevel: zoom.zoomLevel,
|
||||
onSelect: setSelection, onCloseTab: notes.handleCloseTab,
|
||||
onSwitchTab: notes.handleSwitchTab, onReplaceActiveTab: notes.handleReplaceActiveTab,
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
onGoBack: handleGoBack, onGoForward: handleGoForward,
|
||||
canGoBack: navHistory.canGoBack, canGoForward: navHistory.canGoForward,
|
||||
themes: themeManager.themes, activeThemeId: themeManager.activeThemeId,
|
||||
onSwitchTheme: themeManager.switchTheme,
|
||||
onCreateTheme: async () => { await themeManager.createTheme() },
|
||||
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
|
||||
onToggleAIChat: dialogs.toggleAIChat,
|
||||
})
|
||||
|
||||
const { status: updateStatus, actions: updateActions } = useUpdater()
|
||||
|
||||
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist
|
||||
if (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing') {
|
||||
const defaultPath = onboarding.state.defaultPath
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<WelcomeScreen
|
||||
mode={onboarding.state.status === 'welcome' ? 'welcome' : 'vault-missing'}
|
||||
missingPath={onboarding.state.status === 'vault-missing' ? onboarding.state.vaultPath : undefined}
|
||||
defaultVaultPath={defaultPath}
|
||||
onCreateVault={onboarding.handleCreateVault}
|
||||
onOpenFolder={onboarding.handleOpenFolder}
|
||||
creating={onboarding.creating}
|
||||
error={onboarding.error}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show loading spinner while checking vault
|
||||
if (onboarding.state.status === 'loading') {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--sidebar)' }}>
|
||||
<span style={{ color: 'var(--muted-foreground)', fontSize: 14 }}>Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<div className="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} onReorderSections={entryActions.handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
|
||||
<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} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -266,7 +340,7 @@ function App() {
|
||||
{noteListVisible && (
|
||||
<>
|
||||
<div className="app__note-list" style={{ width: layout.noteListWidth }}>
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} getNoteStatus={vault.getNoteStatus} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
</>
|
||||
@@ -297,34 +371,38 @@ function App() {
|
||||
onAddProperty={notes.handleAddProperty}
|
||||
showAIChat={dialogs.showAIChat}
|
||||
onToggleAIChat={dialogs.toggleAIChat}
|
||||
vaultPath={vaultSwitcher.vaultPath}
|
||||
vaultPath={resolvedPath}
|
||||
onTrashNote={entryActions.handleTrashNote}
|
||||
onRestoreNote={entryActions.handleRestoreNote}
|
||||
onArchiveNote={entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={entryActions.handleUnarchiveNote}
|
||||
onRenameTab={handleRenameTab}
|
||||
onContentChange={handleContentChange}
|
||||
onTitleSync={handleTitleSync}
|
||||
canGoBack={navHistory.canGoBack}
|
||||
canGoForward={navHistory.canGoForward}
|
||||
onGoBack={handleGoBack}
|
||||
onGoForward={handleGoForward}
|
||||
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} />
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={vaultSwitcher.vaultPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} />
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} themeManager={themeManager} />
|
||||
<GitHubVaultModal
|
||||
open={dialogs.showGitHubVault}
|
||||
githubToken={settings.github_token}
|
||||
onClose={dialogs.closeGitHubVault}
|
||||
onVaultCloned={vaultSwitcher.handleVaultCloned}
|
||||
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
|
||||
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
X, Plus, PaperPlaneRight, Copy, ArrowClockwise,
|
||||
TextIndent, Sparkle, Key, MagnifyingGlass, Minus,
|
||||
TextIndent, Sparkle, MagnifyingGlass, Minus,
|
||||
} from '@phosphor-icons/react'
|
||||
import {
|
||||
type ChatMessage, getApiKey, setApiKey,
|
||||
buildSystemPrompt, MODEL_OPTIONS,
|
||||
type ChatMessage,
|
||||
buildSystemPrompt,
|
||||
} from '../utils/ai-chat'
|
||||
import { useAIChat } from '../hooks/useAIChat'
|
||||
|
||||
@@ -98,30 +98,6 @@ function ContextSearchDropdown({
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeyDialog({ onClose }: { onClose: () => void }) {
|
||||
const [key, setKey] = useState(getApiKey())
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ background: 'rgba(0,0,0,0.4)' }}>
|
||||
<div className="bg-background border border-border rounded-lg shadow-xl" style={{ width: 400, padding: 20 }}>
|
||||
<h3 className="text-foreground" style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>Anthropic API Key</h3>
|
||||
<p className="text-muted-foreground" style={{ fontSize: 12, margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
Enter your Anthropic API key. Stored locally in your browser.
|
||||
</p>
|
||||
<input type="password" value={key} onChange={e => setKey(e.target.value)} placeholder="sk-ant-..."
|
||||
className="w-full border border-border bg-transparent text-foreground rounded"
|
||||
style={{ fontSize: 13, padding: '8px 10px', outline: 'none', marginBottom: 12 }} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="border border-border bg-transparent text-foreground rounded cursor-pointer hover:bg-accent"
|
||||
style={{ fontSize: 12, padding: '6px 14px' }} onClick={onClose}>Cancel</button>
|
||||
<button className="border-none rounded cursor-pointer"
|
||||
style={{ fontSize: 12, padding: '6px 14px', background: 'var(--primary)', color: 'white' }}
|
||||
onClick={() => { setApiKey(key); onClose() }}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => void }) {
|
||||
return (
|
||||
<div>
|
||||
@@ -207,13 +183,11 @@ function useContextNotes(entry: VaultEntry | null) {
|
||||
|
||||
export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChatPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [model, setModel] = useState<string>(MODEL_OPTIONS[0].value)
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const ctx = useContextNotes(entry)
|
||||
const chat = useAIChat(entry, allContent, ctx.contextNotes, model)
|
||||
const chat = useAIChat(allContent, ctx.contextNotes)
|
||||
|
||||
const contextInfo = useMemo(
|
||||
() => buildSystemPrompt(ctx.contextNotes, allContent),
|
||||
@@ -232,7 +206,7 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat
|
||||
|
||||
return (
|
||||
<aside className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground">
|
||||
<PanelHeader onApiKey={() => setShowApiKeyDialog(true)} onClear={chat.clearConversation} onClose={onClose} />
|
||||
<PanelHeader onClear={chat.clearConversation} onClose={onClose} />
|
||||
|
||||
<ContextBar
|
||||
notes={ctx.contextNotes} entries={entries} contextPaths={ctx.paths}
|
||||
@@ -250,11 +224,9 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat
|
||||
<QuickActionsBar actions={QUICK_ACTIONS} disabled={chat.isStreaming}
|
||||
onAction={msg => { chat.sendMessage(msg); setInput('') }} />
|
||||
|
||||
<InputArea model={model} onModelChange={setModel} input={input} onInputChange={setInput}
|
||||
<InputArea input={input} onInputChange={setInput}
|
||||
onKeyDown={handleKeyDown} onSend={handleSend} disabled={chat.isStreaming || !input.trim()} />
|
||||
|
||||
{showApiKeyDialog && <ApiKeyDialog onClose={() => setShowApiKeyDialog(false)} />}
|
||||
|
||||
<style>{`
|
||||
.typing-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
@@ -272,15 +244,11 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat
|
||||
|
||||
// --- Extracted layout sections ---
|
||||
|
||||
function PanelHeader({ onApiKey, onClear, onClose }: { onApiKey: () => void; onClear: () => void; onClose: () => void }) {
|
||||
function PanelHeader({ onClear, onClose }: { onClear: () => void; onClose: () => void }) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center border-b border-border" style={{ height: 45, padding: '0 12px', gap: 8 }}>
|
||||
<Sparkle size={16} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>AI Chat</span>
|
||||
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onApiKey} title="API Key settings">
|
||||
<Key size={14} weight={getApiKey() ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onClear} title="New conversation"><Plus size={16} /></button>
|
||||
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
@@ -332,9 +300,7 @@ function MessageList({
|
||||
<div className="flex flex-col items-center justify-center text-center text-muted-foreground" style={{ paddingTop: 40 }}>
|
||||
<Sparkle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
|
||||
<p style={{ fontSize: 13, margin: '0 0 4px' }}>Ask anything about your notes</p>
|
||||
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
|
||||
{getApiKey() ? 'Connected to Anthropic API' : 'Set API key for real AI responses'}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>Powered by Claude CLI</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, idx) => (
|
||||
@@ -371,21 +337,13 @@ function QuickActionsBar({
|
||||
}
|
||||
|
||||
function InputArea({
|
||||
model, onModelChange, input, onInputChange, onKeyDown, onSend, disabled,
|
||||
input, onInputChange, onKeyDown, onSend, disabled,
|
||||
}: {
|
||||
model: string; onModelChange: (m: string) => void
|
||||
input: string; onInputChange: (v: string) => void
|
||||
onKeyDown: (e: React.KeyboardEvent) => void; onSend: () => void; disabled: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col border-t border-border" style={{ padding: '8px 12px' }}>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<select value={model} onChange={e => onModelChange(e.target.value)}
|
||||
className="border border-border bg-transparent text-muted-foreground"
|
||||
style={{ fontSize: 11, borderRadius: 4, padding: '2px 6px', outline: 'none' }}>
|
||||
{MODEL_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea value={input} onChange={e => onInputChange(e.target.value)} onKeyDown={onKeyDown}
|
||||
placeholder="Ask about your notes..." rows={1}
|
||||
|
||||
62
src/components/AiActionCard.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { AiActionCard } from './AiActionCard'
|
||||
|
||||
describe('AiActionCard', () => {
|
||||
it('renders label text', () => {
|
||||
render(<AiActionCard tool="create_note" label="Created test.md" status="done" />)
|
||||
expect(screen.getByText('Created test.md')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows pending spinner', () => {
|
||||
render(<AiActionCard tool="search_notes" label="Searching..." status="pending" />)
|
||||
expect(screen.getByTestId('status-pending')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows done check', () => {
|
||||
render(<AiActionCard tool="create_note" label="Created" status="done" />)
|
||||
expect(screen.getByTestId('status-done')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows error icon', () => {
|
||||
render(<AiActionCard tool="delete_note" label="Failed" status="error" />)
|
||||
expect(screen.getByTestId('status-error')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('is clickable when path and onOpenNote provided', () => {
|
||||
const onOpenNote = vi.fn()
|
||||
render(<AiActionCard tool="create_note" label="Open note" path="/vault/test.md" status="done" onOpenNote={onOpenNote} />)
|
||||
fireEvent.click(screen.getByTestId('ai-action-card'))
|
||||
expect(onOpenNote).toHaveBeenCalledWith('/vault/test.md')
|
||||
})
|
||||
|
||||
it('has button role when clickable', () => {
|
||||
render(<AiActionCard tool="create_note" label="Open note" path="/vault/test.md" status="done" onOpenNote={vi.fn()} />)
|
||||
expect(screen.getByRole('button')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('is not clickable without path', () => {
|
||||
const onOpenNote = vi.fn()
|
||||
render(<AiActionCard tool="create_note" label="Created" status="done" onOpenNote={onOpenNote} />)
|
||||
fireEvent.click(screen.getByTestId('ai-action-card'))
|
||||
expect(onOpenNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is not clickable without onOpenNote', () => {
|
||||
render(<AiActionCard tool="create_note" label="Created" path="/vault/test.md" status="done" />)
|
||||
const card = screen.getByTestId('ai-action-card')
|
||||
expect(card.getAttribute('role')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses lighter background for ui_ tools', () => {
|
||||
render(<AiActionCard tool="ui_open_tab" label="Opened tab" status="done" />)
|
||||
const card = screen.getByTestId('ai-action-card')
|
||||
expect(card.style.background).toContain('0.06')
|
||||
})
|
||||
|
||||
it('uses standard background for vault tools', () => {
|
||||
render(<AiActionCard tool="create_note" label="Created" status="done" />)
|
||||
const card = screen.getByTestId('ai-action-card')
|
||||
expect(card.style.background).toContain('0.1')
|
||||
})
|
||||
})
|
||||
69
src/components/AiActionCard.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
PencilSimple, MagnifyingGlass, Link, Trash, ChartBar, Eye, Sparkle,
|
||||
CircleNotch, CheckCircle, XCircle,
|
||||
} from '@phosphor-icons/react'
|
||||
|
||||
export type AiActionStatus = 'pending' | 'done' | 'error'
|
||||
|
||||
export interface AiActionCardProps {
|
||||
tool: string
|
||||
label: string
|
||||
path?: string
|
||||
status: AiActionStatus
|
||||
onOpenNote?: (path: string) => void
|
||||
}
|
||||
|
||||
type IconRenderer = (size: number) => ReactNode
|
||||
|
||||
const TOOL_ICON_MAP: Record<string, IconRenderer> = {
|
||||
create_note: (s) => <PencilSimple size={s} />,
|
||||
edit_note_frontmatter: (s) => <PencilSimple size={s} />,
|
||||
append_to_note: (s) => <PencilSimple size={s} />,
|
||||
search_notes: (s) => <MagnifyingGlass size={s} />,
|
||||
list_notes: (s) => <MagnifyingGlass size={s} />,
|
||||
link_notes: (s) => <Link size={s} />,
|
||||
delete_note: (s) => <Trash size={s} />,
|
||||
vault_context: (s) => <ChartBar size={s} />,
|
||||
ui_open_note: (s) => <Eye size={s} />,
|
||||
ui_open_tab: (s) => <Eye size={s} />,
|
||||
ui_highlight: (s) => <Sparkle size={s} />,
|
||||
ui_set_filter: (s) => <Sparkle size={s} />,
|
||||
}
|
||||
|
||||
const DEFAULT_ICON: IconRenderer = (s) => <PencilSimple size={s} />
|
||||
|
||||
function StatusIndicator({ status }: { status: AiActionStatus }) {
|
||||
if (status === 'pending') {
|
||||
return <CircleNotch size={14} className="ai-spin text-muted-foreground" data-testid="status-pending" />
|
||||
}
|
||||
if (status === 'done') {
|
||||
return <CheckCircle size={14} weight="fill" style={{ color: 'var(--accent-green)' }} data-testid="status-done" />
|
||||
}
|
||||
return <XCircle size={14} weight="fill" style={{ color: 'var(--destructive)' }} data-testid="status-error" />
|
||||
}
|
||||
|
||||
export function AiActionCard({ tool, label, path, status, onOpenNote }: AiActionCardProps) {
|
||||
const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON
|
||||
const isClickable = !!path && !!onOpenNote
|
||||
const isUiTool = tool.startsWith('ui_')
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: 12,
|
||||
background: isUiTool ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={isClickable ? () => onOpenNote(path) : undefined}
|
||||
role={isClickable ? 'button' : undefined}
|
||||
data-testid="ai-action-card"
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">{renderIcon(14)}</span>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
<StatusIndicator status={status} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
src/components/AiMessage.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
|
||||
describe('AiMessage', () => {
|
||||
it('renders user message', () => {
|
||||
render(<AiMessage userMessage="Hello AI" actions={[]} />)
|
||||
expect(screen.getByText('Hello AI')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders response text', () => {
|
||||
render(<AiMessage userMessage="Ask" actions={[]} response="Here is the answer" />)
|
||||
expect(screen.getByText('Here is the answer')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows undo button with response', () => {
|
||||
render(<AiMessage userMessage="Ask" actions={[]} response="Done" />)
|
||||
expect(screen.getByTestId('undo-button')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders reasoning toggle collapsed by default', () => {
|
||||
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." actions={[]} />)
|
||||
expect(screen.getByTestId('reasoning-toggle')).toBeTruthy()
|
||||
expect(screen.queryByTestId('reasoning-content')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands reasoning on toggle click', () => {
|
||||
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." actions={[]} />)
|
||||
fireEvent.click(screen.getByTestId('reasoning-toggle'))
|
||||
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
|
||||
expect(screen.getByText('Thinking about it...')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('collapses reasoning on second click', () => {
|
||||
render(<AiMessage userMessage="Ask" reasoning="Thinking..." actions={[]} />)
|
||||
fireEvent.click(screen.getByTestId('reasoning-toggle'))
|
||||
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
|
||||
fireEvent.click(screen.getByTestId('reasoning-toggle'))
|
||||
expect(screen.queryByTestId('reasoning-content')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders action cards', () => {
|
||||
render(
|
||||
<AiMessage
|
||||
userMessage="Do something"
|
||||
actions={[
|
||||
{ tool: 'create_note', label: 'Created test.md', status: 'done' },
|
||||
{ tool: 'search_notes', label: 'Searched', status: 'pending' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('ai-action-card')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('passes onOpenNote to action cards', () => {
|
||||
const onOpenNote = vi.fn()
|
||||
render(
|
||||
<AiMessage
|
||||
userMessage="Do"
|
||||
actions={[{ tool: 'create_note', label: 'Open', path: '/vault/note.md', status: 'done' }]}
|
||||
onOpenNote={onOpenNote}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('ai-action-card'))
|
||||
expect(onOpenNote).toHaveBeenCalledWith('/vault/note.md')
|
||||
})
|
||||
|
||||
it('shows streaming indicator when streaming without response', () => {
|
||||
const { container } = render(
|
||||
<AiMessage userMessage="Ask" actions={[]} isStreaming />,
|
||||
)
|
||||
expect(container.querySelector('.typing-dot')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not show streaming indicator when response is present', () => {
|
||||
const { container } = render(
|
||||
<AiMessage userMessage="Ask" actions={[]} response="Done" isStreaming />,
|
||||
)
|
||||
expect(container.querySelector('.typing-dot')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render reasoning block when no reasoning', () => {
|
||||
render(<AiMessage userMessage="Ask" actions={[]} />)
|
||||
expect(screen.queryByTestId('reasoning-toggle')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render actions when empty array', () => {
|
||||
render(<AiMessage userMessage="Ask" actions={[]} />)
|
||||
expect(screen.queryByTestId('ai-action-card')).toBeNull()
|
||||
})
|
||||
})
|
||||
134
src/components/AiMessage.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react'
|
||||
import { CaretRight, CaretDown, Brain, ArrowCounterClockwise } from '@phosphor-icons/react'
|
||||
import { AiActionCard, type AiActionStatus } from './AiActionCard'
|
||||
|
||||
export interface AiAction {
|
||||
tool: string
|
||||
label: string
|
||||
path?: string
|
||||
status: AiActionStatus
|
||||
}
|
||||
|
||||
export interface AiMessageProps {
|
||||
userMessage: string
|
||||
reasoning?: string
|
||||
actions: AiAction[]
|
||||
response?: string
|
||||
isStreaming?: boolean
|
||||
onOpenNote?: (path: string) => void
|
||||
}
|
||||
|
||||
function UserBubble({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="flex justify-end" style={{ marginBottom: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--muted)',
|
||||
color: 'var(--foreground)',
|
||||
borderRadius: '12px 12px 2px 12px',
|
||||
maxWidth: '85%',
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReasoningBlock({ text, expanded, onToggle }: {
|
||||
text: string; expanded: boolean; onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<button
|
||||
className="flex items-center gap-1.5 w-full border-none bg-transparent cursor-pointer p-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
style={{ fontSize: 12, padding: '4px 0' }}
|
||||
onClick={onToggle}
|
||||
data-testid="reasoning-toggle"
|
||||
>
|
||||
<Brain size={14} />
|
||||
<span>Reasoning</span>
|
||||
{expanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div
|
||||
className="text-muted-foreground"
|
||||
style={{ fontSize: 12, lineHeight: 1.5, padding: '4px 0 4px 20px' }}
|
||||
data-testid="reasoning-content"
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionCardsList({ actions, onOpenNote }: {
|
||||
actions: AiAction[]; onOpenNote?: (path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1" style={{ marginBottom: 8 }}>
|
||||
{actions.map((action, i) => (
|
||||
<AiActionCard
|
||||
key={`${action.tool}-${i}`}
|
||||
tool={action.tool}
|
||||
label={action.label}
|
||||
path={action.path}
|
||||
status={action.status}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResponseBlock({ text }: { text: string }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.6 }}>{text}</div>
|
||||
<button
|
||||
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
style={{ fontSize: 11, marginTop: 4 }}
|
||||
data-testid="undo-button"
|
||||
>
|
||||
<ArrowCounterClockwise size={12} />
|
||||
<span>Undo</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StreamingIndicator() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground" style={{ fontSize: 12, padding: '4px 0' }}>
|
||||
<div className="flex gap-1">
|
||||
<span className="typing-dot" />
|
||||
<span className="typing-dot" style={{ animationDelay: '0.2s' }} />
|
||||
<span className="typing-dot" style={{ animationDelay: '0.4s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiMessage({ userMessage, reasoning, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
|
||||
const [reasoningExpanded, setReasoningExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div data-testid="ai-message" style={{ marginBottom: 16 }}>
|
||||
<UserBubble content={userMessage} />
|
||||
{reasoning && (
|
||||
<ReasoningBlock
|
||||
text={reasoning}
|
||||
expanded={reasoningExpanded}
|
||||
onToggle={() => setReasoningExpanded(!reasoningExpanded)}
|
||||
/>
|
||||
)}
|
||||
{actions.length > 0 && <ActionCardsList actions={actions} onOpenNote={onOpenNote} />}
|
||||
{response && <ResponseBlock text={response} />}
|
||||
{isStreaming && !response && <StreamingIndicator />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
136
src/components/AiPanel.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { AiPanel } from './AiPanel'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
// Mock the hooks and utils to isolate component tests
|
||||
vi.mock('../hooks/useAiAgent', () => ({
|
||||
useAiAgent: () => ({
|
||||
messages: [],
|
||||
status: 'idle',
|
||||
sendMessage: vi.fn(),
|
||||
clearConversation: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/ai-chat', () => ({
|
||||
nextMessageId: () => `msg-${Date.now()}`,
|
||||
}))
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('AiPanel', () => {
|
||||
it('renders panel with AI Chat header', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByText('AI Chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders data-testid ai-panel', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByTestId('ai-panel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
|
||||
const panel = screen.getByTestId('ai-panel')
|
||||
const buttons = panel.querySelectorAll('button')
|
||||
const closeBtn = Array.from(buttons).find(b => b.title?.includes('Close'))
|
||||
expect(closeBtn).toBeTruthy()
|
||||
fireEvent.click(closeBtn!)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders empty state without context', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByText('Open a note, then ask the AI about it')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders contextual empty state when active entry is provided', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
)
|
||||
expect(screen.getByText('Ask about this note and its linked context')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows context bar with active entry title', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
)
|
||||
expect(screen.getByTestId('context-bar')).toBeTruthy()
|
||||
expect(screen.getByText('My Note')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows linked count in context bar when entry has outgoing links', () => {
|
||||
const linked = makeEntry({ path: '/vault/linked.md', title: 'Linked Note' })
|
||||
const entry = makeEntry({ title: 'My Note', outgoingLinks: ['Linked Note'] })
|
||||
render(
|
||||
<AiPanel
|
||||
onClose={vi.fn()} vaultPath="/tmp/vault"
|
||||
activeEntry={entry} entries={[entry, linked]}
|
||||
allContent={{}}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('+ 1 linked')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not show context bar when no active entry', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.queryByTestId('context-bar')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders input field enabled', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toBeTruthy()
|
||||
expect((input as HTMLInputElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('has send button disabled when input is empty', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const sendBtn = screen.getByTestId('agent-send')
|
||||
expect((sendBtn as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows contextual placeholder when active entry exists', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask about this note...')
|
||||
})
|
||||
|
||||
it('shows generic placeholder when no active entry', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask the AI agent...')
|
||||
})
|
||||
})
|
||||
207
src/components/AiPanel.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
|
||||
import { collectLinkedEntries, buildContextualPrompt } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export type { AiAgentMessage } from '../hooks/useAiAgent'
|
||||
|
||||
interface AiPanelProps {
|
||||
onClose: () => void
|
||||
onOpenNote?: (path: string) => void
|
||||
vaultPath: string
|
||||
activeEntry?: VaultEntry | null
|
||||
entries?: VaultEntry[]
|
||||
allContent?: Record<string, string>
|
||||
}
|
||||
|
||||
function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center border-b border-border"
|
||||
style={{ height: 45, padding: '0 12px', gap: 8 }}
|
||||
>
|
||||
<Robot size={16} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
AI Chat
|
||||
</span>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onClear}
|
||||
title="New conversation"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onClose}
|
||||
title="Close AI panel"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextBar({ activeEntry, linkedCount }: { activeEntry: VaultEntry; linkedCount: number }) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center border-b border-border text-muted-foreground"
|
||||
style={{ padding: '6px 12px', gap: 6, fontSize: 11 }}
|
||||
data-testid="context-bar"
|
||||
>
|
||||
<Link size={12} className="shrink-0" />
|
||||
<span className="truncate" style={{ fontWeight: 500 }}>{activeEntry.title}</span>
|
||||
{linkedCount > 0 && (
|
||||
<span style={{ opacity: 0.6 }}>+ {linkedCount} linked</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ hasContext }: { hasContext: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center text-center text-muted-foreground"
|
||||
style={{ paddingTop: 40 }}
|
||||
>
|
||||
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
|
||||
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
|
||||
{hasContext
|
||||
? 'Ask about this note and its linked context'
|
||||
: 'Open a note, then ask the AI about it'
|
||||
}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
|
||||
{hasContext
|
||||
? 'Summarize, find connections, expand ideas'
|
||||
: 'The AI will use the active note as context'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
|
||||
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; hasContext: boolean
|
||||
}) {
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, isActive])
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
|
||||
{messages.length === 0 && !isActive && <EmptyState hasContext={hasContext} />}
|
||||
{messages.map((msg, i) => (
|
||||
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} />
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext }: {
|
||||
input: string; onInputChange: (v: string) => void
|
||||
onSend: () => void; onKeyDown: (e: React.KeyboardEvent) => void
|
||||
isActive: boolean; hasContext: boolean
|
||||
}) {
|
||||
const sendDisabled = isActive || !input.trim()
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-t border-border"
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<input
|
||||
value={input}
|
||||
onChange={e => onInputChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
className="flex-1 border border-border bg-transparent text-foreground"
|
||||
style={{
|
||||
fontSize: 13, borderRadius: 8, padding: '8px 10px',
|
||||
outline: 'none', fontFamily: 'inherit',
|
||||
}}
|
||||
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
|
||||
disabled={isActive}
|
||||
data-testid="agent-input"
|
||||
/>
|
||||
<button
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: sendDisabled ? 'var(--muted)' : 'var(--primary)',
|
||||
color: sendDisabled ? 'var(--muted-foreground)' : 'white',
|
||||
borderRadius: 8, width: 32, height: 34,
|
||||
cursor: sendDisabled ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onClick={onSend}
|
||||
disabled={sendDisabled}
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent }: AiPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
|
||||
const linkedEntries = useMemo(() => {
|
||||
if (!activeEntry || !entries) return []
|
||||
return collectLinkedEntries(activeEntry, entries)
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !allContent) return undefined
|
||||
return buildContextualPrompt(activeEntry, linkedEntries, allContent)
|
||||
}, [activeEntry, linkedEntries, allContent])
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt)
|
||||
const hasContext = !!activeEntry
|
||||
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isActive) return
|
||||
agent.sendMessage(input)
|
||||
setInput('')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground"
|
||||
data-testid="ai-panel"
|
||||
>
|
||||
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
|
||||
{activeEntry && (
|
||||
<ContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
|
||||
)}
|
||||
<MessageHistory
|
||||
messages={agent.messages}
|
||||
isActive={isActive}
|
||||
onOpenNote={onOpenNote}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
<InputBar
|
||||
input={input}
|
||||
onInputChange={setInput}
|
||||
onSend={handleSend}
|
||||
onKeyDown={handleKeyDown}
|
||||
isActive={isActive}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -156,12 +156,12 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
}}
|
||||
>
|
||||
{/* Left: breadcrumb */}
|
||||
<div className="flex items-center gap-1" style={{ fontSize: 12 }}>
|
||||
<span className="text-muted-foreground">{entry.isA || 'Note'}</span>
|
||||
<span className="text-muted-foreground" style={{ margin: '0 2px' }}>›</span>
|
||||
<span className="font-medium text-foreground">{entry.title}</span>
|
||||
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>·</span>
|
||||
<span className="text-muted-foreground">{wordCount.toLocaleString()} words</span>
|
||||
<div className="flex items-center gap-1 min-w-0 whitespace-nowrap" style={{ fontSize: 12 }}>
|
||||
<span className="shrink-0 text-muted-foreground">{entry.isA || 'Note'}</span>
|
||||
<span className="shrink-0 text-muted-foreground" style={{ margin: '0 2px' }}>›</span>
|
||||
<span className="truncate font-medium text-foreground" style={{ maxWidth: '40vw' }}>{entry.title}</span>
|
||||
<span className="shrink-0 text-muted-foreground" style={{ margin: '0 4px' }}>·</span>
|
||||
<span className="shrink-0 text-muted-foreground">{wordCount.toLocaleString()} words</span>
|
||||
{noteStatus === 'pendingSave' && (
|
||||
<>
|
||||
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>·</span>
|
||||
|
||||
@@ -51,6 +51,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -1012,7 +1013,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByText('\u2713 Yes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stores actual boolean value when adding boolean property', () => {
|
||||
it('stores actual boolean value when adding boolean property', { timeout: 15_000 }, () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -1034,7 +1035,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(onAddProperty).toHaveBeenCalledWith('published', true)
|
||||
})
|
||||
|
||||
it('shows date picker trigger when date type selected', () => {
|
||||
it('shows date picker trigger when date type selected', { timeout: 15_000 }, () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -1050,7 +1051,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByText('Pick a date\u2026')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows status dropdown when status type selected', () => {
|
||||
it('shows status dropdown when status type selected', { timeout: 15_000 }, () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useMemo, useState, useCallback, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
|
||||
import { isUrlValue } from '../utils/url'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link } from 'lucide-react'
|
||||
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag } from 'lucide-react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { getTypeIcon } from './NoteItem'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import {
|
||||
type PropertyDisplayMode,
|
||||
@@ -22,7 +25,8 @@ import {
|
||||
detectPropertyType,
|
||||
} from '../utils/propertyTypes'
|
||||
import { StatusPill, StatusDropdown } from './StatusDropdown'
|
||||
import { SUGGESTED_STATUSES } from '../utils/statusStyles'
|
||||
import { TagsDropdown } from './TagsDropdown'
|
||||
import { getTagStyle } from '../utils/tagStyles'
|
||||
|
||||
// Keys that are relationships (contain wikilinks)
|
||||
export const RELATIONSHIP_KEYS = new Set([
|
||||
@@ -94,6 +98,74 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
|
||||
)
|
||||
}
|
||||
|
||||
function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }: {
|
||||
propKey: string; value: string[]; isEditing: boolean; vaultTags: string[]
|
||||
onSave: (key: string, items: string[]) => void; onStartEdit: (key: string | null) => void
|
||||
}) {
|
||||
const handleToggle = useCallback((tag: string) => {
|
||||
const idx = value.indexOf(tag)
|
||||
const next = idx >= 0 ? value.filter((_, i) => i !== idx) : [...value, tag]
|
||||
onSave(propKey, next)
|
||||
}, [propKey, value, onSave])
|
||||
|
||||
const handleRemove = useCallback((tag: string) => {
|
||||
onSave(propKey, value.filter(t => t !== tag))
|
||||
}, [propKey, value, onSave])
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex min-w-0 flex-wrap items-center gap-1">
|
||||
{value.map(tag => {
|
||||
const style = getTagStyle(tag)
|
||||
return (
|
||||
<span
|
||||
key={tag}
|
||||
className="group/tag relative inline-flex items-center overflow-hidden rounded-full"
|
||||
style={{ backgroundColor: style.bg, padding: '1px 6px', maxWidth: 120 }}
|
||||
>
|
||||
<span
|
||||
className="transition-[max-width] duration-150 group-hover/tag:[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]"
|
||||
style={{
|
||||
color: style.color,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '1.2px',
|
||||
textTransform: 'uppercase' as const,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
<button
|
||||
className="ml-0.5 max-w-0 overflow-hidden border-none bg-transparent p-0 leading-none opacity-0 transition-all duration-150 group-hover/tag:max-w-[14px] group-hover/tag:opacity-100"
|
||||
style={{ color: style.color, fontSize: 10, flexShrink: 0 }}
|
||||
onClick={() => handleRemove(tag)}
|
||||
title={`Remove ${tag}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
className="inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-dashed border-muted-foreground bg-transparent text-[10px] text-muted-foreground transition-colors hover:border-foreground hover:text-foreground"
|
||||
onClick={() => onStartEdit(propKey)}
|
||||
title="Add tag"
|
||||
data-testid="tags-add-button"
|
||||
>+</button>
|
||||
{isEditing && (
|
||||
<TagsDropdown
|
||||
selectedTags={value}
|
||||
vaultTags={vaultTags}
|
||||
onToggle={handleToggle}
|
||||
onClose={() => onStartEdit(null)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button
|
||||
@@ -149,7 +221,7 @@ function DateValue({ value, onSave }: {
|
||||
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ''}`}>{formatted || 'Pick a date\u2026'}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end" data-testid="date-picker-popover">
|
||||
<PopoverContent className="w-auto p-0" align="end" side="left" data-testid="date-picker-popover">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
@@ -180,6 +252,7 @@ const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [
|
||||
{ value: 'boolean', label: 'Boolean' },
|
||||
{ value: 'status', label: 'Status' },
|
||||
{ value: 'url', label: 'URL' },
|
||||
{ value: 'tags', label: 'Tags' },
|
||||
]
|
||||
|
||||
function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
|
||||
@@ -187,7 +260,19 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
|
||||
onSelect: (key: string, mode: PropertyDisplayMode | null) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const positionMenu = useCallback((node: HTMLDivElement | null) => {
|
||||
if (!node) return
|
||||
const el = triggerRef.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const menuW = 140
|
||||
let left = rect.right - menuW
|
||||
if (left < 8) left = 8
|
||||
node.style.top = `${rect.bottom + 4}px`
|
||||
node.style.left = `${left}px`
|
||||
}, [])
|
||||
|
||||
const handleSelect = (mode: PropertyDisplayMode) => {
|
||||
if (mode === autoMode) {
|
||||
@@ -199,8 +284,9 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className="flex h-4 w-4 items-center justify-center rounded border-none bg-transparent p-0 text-[10px] leading-none text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover/prop:opacity-100"
|
||||
onClick={() => setOpen(!open)}
|
||||
title="Change display mode"
|
||||
@@ -208,47 +294,53 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
|
||||
>
|
||||
{'\u25BE'}
|
||||
</button>
|
||||
{open && (
|
||||
{open && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[12000]" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="absolute right-0 top-full z-[12001] mt-1 min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md"
|
||||
ref={positionMenu}
|
||||
className="fixed z-[12001] min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md"
|
||||
data-testid="display-mode-menu"
|
||||
>
|
||||
{DISPLAY_MODE_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className="flex w-full items-center gap-2 border-none bg-transparent px-3 py-1.5 text-left text-[12px] text-foreground transition-colors hover:bg-muted"
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
data-testid={`display-mode-option-${opt.value}`}
|
||||
>
|
||||
<span className="w-3 text-center text-[10px]">
|
||||
{currentMode === opt.value ? '\u2713' : ''}
|
||||
</span>
|
||||
{opt.label}
|
||||
{opt.value === autoMode && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">auto</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{DISPLAY_MODE_OPTIONS.map(opt => {
|
||||
const OptIcon = DISPLAY_MODE_ICONS[opt.value]
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
className="flex w-full items-center gap-2 border-none bg-transparent px-3 py-1.5 text-left text-[12px] text-foreground transition-colors hover:bg-muted"
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
data-testid={`display-mode-option-${opt.value}`}
|
||||
>
|
||||
<span className="w-3 text-center text-[10px]">
|
||||
{currentMode === opt.value ? '\u2713' : ''}
|
||||
</span>
|
||||
<OptIcon className="size-3.5 text-muted-foreground" />
|
||||
{opt.label}
|
||||
{opt.value === autoMode && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">auto</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
|
||||
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link,
|
||||
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag,
|
||||
}
|
||||
|
||||
const ADD_INPUT_CLASS = "h-[26px] min-w-0 flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
|
||||
const ADD_INPUT_CLASS = "h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
|
||||
|
||||
function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const boolVal = value.toLowerCase() === 'true'
|
||||
return (
|
||||
<button
|
||||
className="h-[26px] min-w-0 flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-secondary-foreground transition-colors hover:bg-accent"
|
||||
className="h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-secondary-foreground transition-colors hover:bg-accent"
|
||||
onClick={() => onChange(boolVal ? 'false' : 'true')}
|
||||
data-testid="add-property-boolean-toggle"
|
||||
>
|
||||
@@ -264,7 +356,7 @@ function AddDateInput({ value, onChange }: { value: string; onChange: (v: string
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="inline-flex h-[26px] min-w-0 flex-1 cursor-pointer items-center gap-1 rounded border border-border bg-muted px-1.5 text-[12px] transition-colors hover:bg-accent"
|
||||
className="inline-flex h-[26px] min-w-[60px] flex-1 cursor-pointer items-center gap-1 rounded border border-border bg-muted px-1.5 text-[12px] transition-colors hover:bg-accent"
|
||||
data-testid="add-property-date-trigger"
|
||||
>
|
||||
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
@@ -273,7 +365,7 @@ function AddDateInput({ value, onChange }: { value: string; onChange: (v: string
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<PopoverContent className="w-auto p-0" align="start" side="left">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
@@ -286,23 +378,25 @@ function AddDateInput({ value, onChange }: { value: string; onChange: (v: string
|
||||
}
|
||||
|
||||
function AddStatusInput({ value, onChange, vaultStatuses }: { value: string; onChange: (v: string) => void; vaultStatuses: string[] }) {
|
||||
const allStatuses = [...new Set([...vaultStatuses, ...SUGGESTED_STATUSES])]
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
return (
|
||||
<Select value={value || undefined} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-[26px] min-w-0 flex-1 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
|
||||
style={{ fontSize: 12, borderRadius: 4 }}
|
||||
<span className="relative inline-flex min-w-[60px] flex-1 items-center">
|
||||
<button
|
||||
className="inline-flex h-[26px] min-w-[60px] flex-1 cursor-pointer items-center gap-1 rounded border border-border bg-muted px-1.5 text-[12px] transition-colors hover:bg-accent"
|
||||
onClick={() => setShowDropdown(true)}
|
||||
data-testid="add-property-status-trigger"
|
||||
>
|
||||
<SelectValue placeholder="Status\u2026" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allStatuses.map(s => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{value ? <StatusPill status={value} /> : <span className="text-muted-foreground">Status{'\u2026'}</span>}
|
||||
</button>
|
||||
{showDropdown && (
|
||||
<StatusDropdown
|
||||
value={value}
|
||||
vaultStatuses={vaultStatuses}
|
||||
onSave={(v) => { onChange(v); setShowDropdown(false) }}
|
||||
onCancel={() => setShowDropdown(false)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -314,6 +408,11 @@ function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultS
|
||||
case 'boolean': return <AddBooleanInput value={value} onChange={onChange} />
|
||||
case 'date': return <AddDateInput value={value} onChange={onChange} />
|
||||
case 'status': return <AddStatusInput value={value} onChange={onChange} vaultStatuses={vaultStatuses} />
|
||||
case 'tags': return (
|
||||
<input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
|
||||
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
|
||||
/>
|
||||
)
|
||||
default: return (
|
||||
<input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
|
||||
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
|
||||
@@ -342,22 +441,22 @@ function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
|
||||
<input
|
||||
className="h-[26px] w-[90px] shrink-0 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
|
||||
className="h-[26px] w-20 shrink-0 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
|
||||
type="text" placeholder="Property name" value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus
|
||||
/>
|
||||
<Select value={displayMode} onValueChange={(v) => handleModeChange(v as PropertyDisplayMode)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-[26px] w-[82px] shrink-0 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
|
||||
className="h-[26px] w-[72px] shrink-0 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
|
||||
style={{ fontSize: 12, borderRadius: 4 }}
|
||||
data-testid="add-property-type-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent position="popper" side="left">
|
||||
{DISPLAY_MODE_OPTIONS.map(opt => {
|
||||
const OptIcon = DISPLAY_MODE_ICONS[opt.value]
|
||||
return (
|
||||
@@ -389,7 +488,7 @@ const TYPE_NONE = '__none__'
|
||||
function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
|
||||
if (!isA) return null
|
||||
return (
|
||||
<div className="flex items-center justify-between px-1.5">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5">
|
||||
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
|
||||
{onNavigate ? (
|
||||
<button
|
||||
@@ -404,8 +503,24 @@ function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null
|
||||
)
|
||||
}
|
||||
|
||||
function TypeSelector({ isA, customColorKey, availableTypes, onUpdateProperty, onNavigate }: {
|
||||
function TypeSelectorItem({ type, typeColorKeys, typeIconKeys }: {
|
||||
type: string; typeColorKeys: Record<string, string | null>; typeIconKeys: Record<string, string | null>
|
||||
}) {
|
||||
const Icon = getTypeIcon(type, typeIconKeys[type])
|
||||
const color = getTypeColor(type, typeColorKeys[type])
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon from static map lookup */}
|
||||
<Icon width={14} height={14} style={{ color }} />
|
||||
{type}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TypeSelector({ isA, customColorKey, availableTypes, typeColorKeys, typeIconKeys, onUpdateProperty, onNavigate }: {
|
||||
isA?: string | null; customColorKey?: string | null; availableTypes: string[]
|
||||
typeColorKeys: Record<string, string | null>
|
||||
typeIconKeys: Record<string, string | null>
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onNavigate?: (target: string) => void
|
||||
}) {
|
||||
@@ -417,7 +532,7 @@ function TypeSelector({ isA, customColorKey, availableTypes, onUpdateProperty, o
|
||||
: availableTypes
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-1.5" data-testid="type-selector">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5" data-testid="type-selector">
|
||||
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
|
||||
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
|
||||
<SelectTrigger
|
||||
@@ -427,11 +542,13 @@ function TypeSelector({ isA, customColorKey, availableTypes, onUpdateProperty, o
|
||||
>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent position="popper" side="left">
|
||||
<SelectItem value={TYPE_NONE}>None</SelectItem>
|
||||
<SelectSeparator />
|
||||
{options.map(type => (
|
||||
<SelectItem key={type} value={type}>{type}</SelectItem>
|
||||
<SelectItem key={type} value={type}>
|
||||
<TypeSelectorItem type={type} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -451,20 +568,21 @@ function autoDetectFromValue(value: FrontmatterValue): PropertyDisplayMode {
|
||||
return 'text'
|
||||
}
|
||||
|
||||
function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, onStartEdit, onSave, onSaveList, onUpdate }: {
|
||||
type SmartCellProps = {
|
||||
propKey: string; value: FrontmatterValue; displayMode: PropertyDisplayMode; isEditing: boolean
|
||||
vaultStatuses: string[]
|
||||
vaultStatuses: string[]; vaultTags: string[]
|
||||
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
|
||||
onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void
|
||||
}) {
|
||||
}
|
||||
|
||||
function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate }: SmartCellProps) {
|
||||
const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) }
|
||||
|
||||
if (Array.isArray(value)) return <TagPillList items={value.map(String)} onSave={(items) => onSaveList(propKey, items)} label={propKey} />
|
||||
|
||||
const resolvedMode = displayMode === 'text' ? autoDetectFromValue(value) : displayMode
|
||||
switch (resolvedMode) {
|
||||
case 'status':
|
||||
return <StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
|
||||
case 'tags':
|
||||
return <TagsValue propKey={propKey} value={value ? [String(value)] : []} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
|
||||
case 'date':
|
||||
return <DateValue value={String(value ?? '')} onSave={(v) => onSave(propKey, v)} />
|
||||
case 'boolean': {
|
||||
@@ -478,34 +596,52 @@ function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, vaultS
|
||||
}
|
||||
}
|
||||
|
||||
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
|
||||
function SmartPropertyValueCell(props: SmartCellProps) {
|
||||
const { propKey, value, displayMode, isEditing, vaultTags, onSaveList, onStartEdit } = props
|
||||
if (Array.isArray(value)) {
|
||||
if (displayMode === 'tags') {
|
||||
return <TagsValue propKey={propKey} value={value.map(String)} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
|
||||
}
|
||||
return <TagPillList items={value.map(String)} onSave={(items) => onSaveList(propKey, items)} label={propKey} />
|
||||
}
|
||||
return <ScalarValueCell {...props} />
|
||||
}
|
||||
|
||||
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
|
||||
propKey: string; value: FrontmatterValue; editingKey: string | null
|
||||
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
|
||||
vaultStatuses: string[]
|
||||
vaultStatuses: string[]; vaultTags: string[]
|
||||
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
|
||||
onSaveList: (key: string, items: string[]) => void
|
||||
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
|
||||
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
|
||||
}) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && editingKey !== propKey) {
|
||||
e.preventDefault()
|
||||
onStartEdit(propKey)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/prop flex items-center justify-between rounded px-1.5 py-0.5 transition-colors hover:bg-muted" data-testid="editable-property">
|
||||
<span className="font-mono-overline flex shrink-0 items-center gap-1 text-muted-foreground">
|
||||
{propKey}
|
||||
<div className="group/prop flex min-w-0 items-center justify-between gap-2 rounded px-1.5 py-0.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
|
||||
<span className="font-mono-overline flex min-w-0 items-center gap-1 text-muted-foreground">
|
||||
<span className="truncate">{propKey}</span>
|
||||
{onDelete && (
|
||||
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">×</button>
|
||||
)}
|
||||
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
|
||||
</span>
|
||||
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
|
||||
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-1.5" data-testid="readonly-property">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5" data-testid="readonly-property">
|
||||
<span className="font-mono-overline shrink-0" style={{ color: 'var(--text-muted)' }}>{label}</span>
|
||||
<span className="text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
|
||||
<span className="min-w-0 truncate text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -547,9 +683,17 @@ function reconcileListUpdate(
|
||||
|
||||
function deriveTypeInfo(entries: VaultEntry[] | undefined, entryIsA: string | null) {
|
||||
const typeEntries = (entries ?? []).filter(e => e.isA === 'Type')
|
||||
const typeColorKeys: Record<string, string | null> = {}
|
||||
const typeIconKeys: Record<string, string | null> = {}
|
||||
for (const e of typeEntries) {
|
||||
typeColorKeys[e.title] = e.color ?? null
|
||||
typeIconKeys[e.title] = e.icon ?? null
|
||||
}
|
||||
return {
|
||||
availableTypes: typeEntries.map(e => e.title).sort((a, b) => a.localeCompare(b)),
|
||||
customColorKey: entryIsA ? (typeEntries.find(e => e.title === entryIsA)?.color ?? null) : null,
|
||||
customColorKey: entryIsA ? (typeColorKeys[entryIsA] ?? null) : null,
|
||||
typeColorKeys,
|
||||
typeIconKeys,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,12 +705,39 @@ function collectVaultStatuses(entries: VaultEntry[] | undefined): string[] {
|
||||
return Array.from(seen).sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function mergeArrayFieldsInto(fm: ParsedFrontmatter, tagsByKey: Map<string, Set<string>>): void {
|
||||
for (const [key, value] of Object.entries(fm)) {
|
||||
if (!Array.isArray(value)) continue
|
||||
let set = tagsByKey.get(key)
|
||||
if (!set) { set = new Set(); tagsByKey.set(key, set) }
|
||||
for (const tag of value) set.add(String(tag))
|
||||
}
|
||||
}
|
||||
|
||||
function collectAllVaultTags(entries: VaultEntry[] | undefined, allContent: Record<string, string> | undefined): Record<string, string[]> {
|
||||
if (!entries || !allContent) return {}
|
||||
const tagsByKey = new Map<string, Set<string>>()
|
||||
for (const entry of entries) {
|
||||
const content = allContent[entry.path]
|
||||
if (!content) continue
|
||||
mergeArrayFieldsInto(parseFrontmatter(content), tagsByKey)
|
||||
}
|
||||
const result: Record<string, string[]> = {}
|
||||
for (const [key, set] of tagsByKey) result[key] = Array.from(set).sort((a, b) => a.localeCompare(b))
|
||||
return result
|
||||
}
|
||||
|
||||
function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean {
|
||||
return !SKIP_KEYS.has(key) && !RELATIONSHIP_KEYS.has(key) && !containsWikilinks(value)
|
||||
}
|
||||
|
||||
function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue {
|
||||
return mode === 'boolean' ? rawValue.toLowerCase() === 'true' : parseNewValue(rawValue)
|
||||
if (mode === 'boolean') return rawValue.toLowerCase() === 'true'
|
||||
if (mode === 'tags') {
|
||||
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
|
||||
return items
|
||||
}
|
||||
return parseNewValue(rawValue)
|
||||
}
|
||||
|
||||
function persistModeOverride(key: string, mode: PropertyDisplayMode | null) {
|
||||
@@ -578,19 +749,21 @@ interface PropertyPanelDeps {
|
||||
entries: VaultEntry[] | undefined
|
||||
entryIsA: string | null
|
||||
frontmatter: ParsedFrontmatter
|
||||
allContent: Record<string, string> | undefined
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
}
|
||||
|
||||
function usePropertyPanelState(deps: PropertyPanelDeps) {
|
||||
const { entries, entryIsA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty } = deps
|
||||
const { entries, entryIsA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty } = deps
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [displayOverrides, setDisplayOverrides] = useState(() => loadDisplayModeOverrides())
|
||||
|
||||
const { availableTypes, customColorKey } = useMemo(() => deriveTypeInfo(entries, entryIsA), [entries, entryIsA])
|
||||
const { availableTypes, customColorKey, typeColorKeys, typeIconKeys } = useMemo(() => deriveTypeInfo(entries, entryIsA), [entries, entryIsA])
|
||||
const vaultStatuses = useMemo(() => collectVaultStatuses(entries), [entries])
|
||||
const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries, allContent), [entries, allContent])
|
||||
const propertyEntries = useMemo(() => Object.entries(frontmatter).filter(isVisibleProperty), [frontmatter])
|
||||
|
||||
const handleSaveValue = useCallback((key: string, newValue: string) => {
|
||||
@@ -620,19 +793,20 @@ function usePropertyPanelState(deps: PropertyPanelDeps) {
|
||||
|
||||
return {
|
||||
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
||||
availableTypes, customColorKey, vaultStatuses, propertyEntries,
|
||||
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
|
||||
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
|
||||
}
|
||||
}
|
||||
|
||||
export function DynamicPropertiesPanel({
|
||||
entry, content, frontmatter, entries,
|
||||
entry, content, frontmatter, entries, allContent,
|
||||
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
content: string | null
|
||||
frontmatter: ParsedFrontmatter
|
||||
entries?: VaultEntry[]
|
||||
allContent?: Record<string, string>
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
@@ -640,21 +814,22 @@ export function DynamicPropertiesPanel({
|
||||
}) {
|
||||
const {
|
||||
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
||||
availableTypes, customColorKey, vaultStatuses, propertyEntries,
|
||||
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
|
||||
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
|
||||
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
|
||||
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty })
|
||||
|
||||
const wordCount = countWords(content ?? '')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
|
||||
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<PropertyRow
|
||||
key={key} propKey={key} value={value}
|
||||
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
|
||||
vaultStatuses={vaultStatuses}
|
||||
vaultTags={vaultTagsByKey[key] ?? []}
|
||||
onStartEdit={setEditingKey} onSave={handleSaveValue}
|
||||
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
|
||||
onDelete={onDeleteProperty}
|
||||
|
||||
@@ -211,7 +211,7 @@ export function TagPillList({
|
||||
) : (
|
||||
<span
|
||||
key={idx}
|
||||
className="group/pill inline-flex cursor-pointer items-center gap-0.5 rounded-full py-0.5 pl-2 pr-1 transition-colors"
|
||||
className="group/pill relative inline-flex cursor-pointer items-center rounded-full px-2 py-0.5 transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-blue-light)',
|
||||
color: 'var(--accent-blue)',
|
||||
@@ -223,8 +223,8 @@ export function TagPillList({
|
||||
>
|
||||
{item}
|
||||
<button
|
||||
className="ml-0.5 inline-flex h-3.5 w-3.5 items-center justify-center rounded-full border-none bg-transparent p-0 text-[10px] leading-none opacity-0 transition-all hover:bg-[var(--accent-red-light)] hover:text-[var(--accent-red)] group-hover/pill:opacity-100"
|
||||
style={{ color: 'var(--accent-blue)' }}
|
||||
className="absolute right-0.5 top-1/2 flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-full border-none p-0 text-[10px] leading-none opacity-0 shadow-[-6px_0_4px_-2px_var(--accent-blue-light)] transition-all hover:bg-[var(--accent-red-light)] hover:text-[var(--accent-red)] group-hover/pill:opacity-100"
|
||||
style={{ color: 'var(--accent-blue)', backgroundColor: 'var(--accent-blue-light)' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteItem(idx)
|
||||
|
||||
@@ -75,6 +75,7 @@ const mockEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useEffect, memo } from 'react'
|
||||
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
|
||||
import { useRef, useEffect, useCallback, memo } from 'react'
|
||||
import { useEditorTabSwap, getH1TextFromBlocks } from '../hooks/useEditorTabSwap'
|
||||
import { useHeadingTitleSync } from '../hooks/useHeadingTitleSync'
|
||||
import { useCreateBlockNote } from '@blocknote/react'
|
||||
import '@blocknote/mantine/style.css'
|
||||
import { uploadImageFile } from '../hooks/useImageDrop'
|
||||
@@ -52,10 +53,13 @@ interface EditorProps {
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
onRenameTab?: (path: string, newTitle: string) => void
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
/** Called when H1→title sync updates the title (debounced). */
|
||||
onTitleSync?: (path: string, newTitle: string) => void
|
||||
canGoBack?: boolean
|
||||
canGoForward?: boolean
|
||||
onGoBack?: () => void
|
||||
onGoForward?: () => void
|
||||
leftPanelsCollapsed?: boolean
|
||||
}
|
||||
|
||||
function EditorEmptyState() {
|
||||
@@ -76,8 +80,8 @@ export const Editor = memo(function Editor({
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onRenameTab, onContentChange,
|
||||
canGoBack, canGoForward, onGoBack, onGoForward,
|
||||
onRenameTab, onContentChange, onTitleSync,
|
||||
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
|
||||
}: EditorProps) {
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
|
||||
@@ -87,14 +91,29 @@ export const Editor = memo(function Editor({
|
||||
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
|
||||
})
|
||||
|
||||
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange })
|
||||
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
|
||||
|
||||
const { syncActiveRef, onH1Changed, onManualRename } = useHeadingTitleSync({
|
||||
activeTabPath,
|
||||
currentTitle: activeTab?.entry.title ?? null,
|
||||
onTitleSync: onTitleSync ?? (() => {}),
|
||||
})
|
||||
|
||||
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
|
||||
tabs, activeTabPath, editor, onContentChange,
|
||||
onH1Change: onH1Changed, syncActiveRef,
|
||||
})
|
||||
useEditorFocus(editor, editorMountedRef)
|
||||
|
||||
const handleRenameTabWithSync = useCallback((path: string, newTitle: string) => {
|
||||
const h1Text = getH1TextFromBlocks(editor.document)
|
||||
onManualRename(newTitle, h1Text)
|
||||
onRenameTab?.(path, newTitle)
|
||||
}, [editor, onManualRename, onRenameTab])
|
||||
|
||||
const { diffMode, diffContent, diffLoading, handleToggleDiff, handleViewCommitDiff } = useDiffMode({
|
||||
activeTabPath, onLoadDiff, onLoadDiffAtCommit,
|
||||
})
|
||||
|
||||
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
|
||||
const isLoadingNewTab = activeTabPath !== null && !activeTab
|
||||
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
|
||||
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
|
||||
@@ -110,11 +129,12 @@ export const Editor = memo(function Editor({
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNote={onCreateNote}
|
||||
onReorderTabs={onReorderTabs}
|
||||
onRenameTab={onRenameTab}
|
||||
onRenameTab={handleRenameTabWithSync}
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
onGoBack={onGoBack}
|
||||
onGoForward={onGoForward}
|
||||
leftPanelsCollapsed={leftPanelsCollapsed}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{tabs.length === 0
|
||||
@@ -140,6 +160,7 @@ export const Editor = memo(function Editor({
|
||||
onRestoreNote={onRestoreNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
/>
|
||||
}
|
||||
{showRightPanel && <ResizeHandle onResize={onInspectorResize} />}
|
||||
@@ -152,6 +173,7 @@ export const Editor = memo(function Editor({
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
gitHistory={gitHistory}
|
||||
vaultPath={vaultPath ?? ''}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
@@ -159,6 +181,7 @@ export const Editor = memo(function Editor({
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onOpenNote={onNavigateWikilink}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ interface EditorContentProps {
|
||||
onRestoreNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
vaultPath?: string
|
||||
}
|
||||
|
||||
function EditorLoadingSkeleton() {
|
||||
@@ -96,7 +97,7 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
export function EditorContent({
|
||||
activeTab, isLoadingNewTab, entries, editor,
|
||||
diffMode, diffContent, onToggleDiff,
|
||||
onNavigateWikilink, onEditorChange,
|
||||
onNavigateWikilink, onEditorChange, vaultPath,
|
||||
...breadcrumbProps
|
||||
}: EditorContentProps) {
|
||||
return (
|
||||
@@ -105,7 +106,7 @@ export function EditorContent({
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
{!diffMode && activeTab && (
|
||||
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} />
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} />
|
||||
</div>
|
||||
)}
|
||||
{isLoadingNewTab && !diffMode && <EditorLoadingSkeleton />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import { Inspector, type FrontmatterValue } from './Inspector'
|
||||
import { AIChatPanel } from './AIChatPanel'
|
||||
import { AiPanel } from './AiPanel'
|
||||
|
||||
interface EditorRightPanelProps {
|
||||
showAIChat?: boolean
|
||||
@@ -11,6 +11,7 @@ interface EditorRightPanelProps {
|
||||
entries: VaultEntry[]
|
||||
allContent: Record<string, string>
|
||||
gitHistory: GitCommit[]
|
||||
vaultPath: string
|
||||
onToggleInspector: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onNavigateWikilink: (target: string) => void
|
||||
@@ -18,13 +19,14 @@ interface EditorRightPanelProps {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onOpenNote?: (path: string) => void
|
||||
}
|
||||
|
||||
export function EditorRightPanel({
|
||||
showAIChat, inspectorCollapsed, inspectorWidth,
|
||||
inspectorEntry, inspectorContent, entries, allContent, gitHistory,
|
||||
inspectorEntry, inspectorContent, entries, allContent, gitHistory, vaultPath,
|
||||
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
|
||||
}: EditorRightPanelProps) {
|
||||
if (showAIChat) {
|
||||
return (
|
||||
@@ -32,11 +34,13 @@ export function EditorRightPanel({
|
||||
className="shrink-0 flex flex-col min-h-0"
|
||||
style={{ width: inspectorWidth, height: '100%' }}
|
||||
>
|
||||
<AIChatPanel
|
||||
entry={inspectorEntry}
|
||||
allContent={allContent}
|
||||
entries={entries}
|
||||
<AiPanel
|
||||
onClose={() => onToggleAIChat?.()}
|
||||
onOpenNote={onOpenNote}
|
||||
vaultPath={vaultPath}
|
||||
activeEntry={inspectorEntry}
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -55,6 +59,7 @@ export function EditorRightPanel({
|
||||
entry={inspectorEntry}
|
||||
content={inspectorContent}
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
gitHistory={gitHistory}
|
||||
onNavigate={onNavigateWikilink}
|
||||
onViewCommitDiff={onViewCommitDiff}
|
||||
|
||||
226
src/components/GitHubDeviceFlow.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { GithubLogo, CircleNotch, ArrowClockwise, Copy, Check } from '@phosphor-icons/react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
import type { DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from '../types'
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
type OAuthStatus = 'idle' | 'waiting' | 'error'
|
||||
|
||||
/** Process a device flow poll result. Returns 'done' if auth complete, 'continue' to keep polling. */
|
||||
function processPollResult(
|
||||
result: DeviceFlowPollResult,
|
||||
callbacks: {
|
||||
onComplete: (token: string) => Promise<void>
|
||||
onExpired: () => void
|
||||
onError: (msg: string) => void
|
||||
},
|
||||
): 'done' | 'continue' {
|
||||
if (result.status === 'complete' && result.access_token) {
|
||||
callbacks.onComplete(result.access_token)
|
||||
return 'done'
|
||||
}
|
||||
if (result.status === 'expired') {
|
||||
callbacks.onExpired()
|
||||
return 'done'
|
||||
}
|
||||
if (result.status === 'error') {
|
||||
callbacks.onError(result.error ?? 'Authorization failed.')
|
||||
return 'done'
|
||||
}
|
||||
return 'continue'
|
||||
}
|
||||
|
||||
interface GitHubDeviceFlowProps {
|
||||
onConnected: (token: string, username: string) => void
|
||||
}
|
||||
|
||||
export function GitHubDeviceFlow({ onConnected }: GitHubDeviceFlowProps) {
|
||||
const [oauthStatus, setOauthStatus] = useState<OAuthStatus>('idle')
|
||||
const [userCode, setUserCode] = useState<string | null>(null)
|
||||
const [verificationUri, setVerificationUri] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const pollingRef = useRef(false)
|
||||
const deviceCodeRef = useRef<string | null>(null)
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
pollingRef.current = false
|
||||
deviceCodeRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => { pollingRef.current = false }
|
||||
}, [])
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
setOauthStatus('waiting')
|
||||
setErrorMessage(null)
|
||||
setUserCode(null)
|
||||
|
||||
try {
|
||||
const flowStart = await tauriCall<DeviceFlowStart>('github_device_flow_start')
|
||||
setUserCode(flowStart.user_code)
|
||||
setVerificationUri(flowStart.verification_uri)
|
||||
deviceCodeRef.current = flowStart.device_code
|
||||
openExternalUrl(flowStart.verification_uri).catch(() => {})
|
||||
|
||||
pollingRef.current = true
|
||||
const intervalMs = Math.max(flowStart.interval * 1000, 5000)
|
||||
|
||||
const pollLoop = async () => {
|
||||
while (pollingRef.current && deviceCodeRef.current) {
|
||||
await new Promise(r => setTimeout(r, intervalMs))
|
||||
if (!pollingRef.current) break
|
||||
|
||||
const result = await tauriCall<DeviceFlowPollResult>('github_device_flow_poll', {
|
||||
deviceCode: deviceCodeRef.current,
|
||||
})
|
||||
const outcome = processPollResult(result, {
|
||||
onComplete: async (token) => {
|
||||
const user = await tauriCall<GitHubUser>('github_get_user', { token })
|
||||
stopPolling()
|
||||
setOauthStatus('idle')
|
||||
setUserCode(null)
|
||||
onConnected(token, user.login)
|
||||
},
|
||||
onExpired: () => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage('Authorization expired. Please try again.')
|
||||
},
|
||||
onError: (msg) => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(msg)
|
||||
},
|
||||
})
|
||||
if (outcome === 'done') return
|
||||
}
|
||||
}
|
||||
|
||||
pollLoop().catch(err => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Polling failed.')
|
||||
})
|
||||
} catch (err) {
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Failed to start login.')
|
||||
}
|
||||
}, [onConnected, stopPolling])
|
||||
|
||||
const resetOAuth = useCallback(() => {
|
||||
stopPolling()
|
||||
setOauthStatus('idle')
|
||||
setUserCode(null)
|
||||
setVerificationUri(null)
|
||||
setErrorMessage(null)
|
||||
}, [stopPolling])
|
||||
|
||||
if (oauthStatus === 'waiting' && userCode) {
|
||||
return <DeviceCodeView userCode={userCode} verificationUri={verificationUri} onCancel={resetOAuth} />
|
||||
}
|
||||
|
||||
return <LoginButton onLogin={handleLogin} disabled={oauthStatus === 'waiting'} errorMessage={errorMessage} onRetry={errorMessage ? () => { resetOAuth(); handleLogin() } : undefined} />
|
||||
}
|
||||
|
||||
function DeviceCodeView({ userCode, verificationUri, onCancel }: { userCode: string; verificationUri: string | null; onCancel: () => void }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopyCode = useCallback(() => {
|
||||
navigator.clipboard.writeText(userCode).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}).catch(() => {})
|
||||
}, [userCode])
|
||||
|
||||
const handleOpenUrl = useCallback(() => {
|
||||
if (verificationUri) openExternalUrl(verificationUri).catch(() => {})
|
||||
}, [verificationUri])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }} data-testid="github-waiting">
|
||||
<div
|
||||
className="border border-border rounded px-4 py-3"
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 8, textAlign: 'center' }}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>Enter this code on GitHub:</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div
|
||||
style={{ fontSize: 24, fontWeight: 700, letterSpacing: 4, color: 'var(--foreground)', fontFamily: 'monospace' }}
|
||||
data-testid="github-user-code"
|
||||
>
|
||||
{userCode}
|
||||
</div>
|
||||
<button
|
||||
className="border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={handleCopyCode}
|
||||
title="Copy code"
|
||||
data-testid="github-copy-code"
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{verificationUri && (
|
||||
<button
|
||||
className="border-none bg-transparent text-muted-foreground cursor-pointer hover:text-foreground underline"
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={handleOpenUrl}
|
||||
data-testid="github-open-url"
|
||||
>
|
||||
{verificationUri}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-2" style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>
|
||||
<CircleNotch size={14} className="animate-spin" />
|
||||
Waiting for authorization...
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground"
|
||||
style={{ fontSize: 12, padding: '6px 12px', alignSelf: 'center' }}
|
||||
onClick={onCancel}
|
||||
data-testid="github-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginButton({ onLogin, disabled, errorMessage, onRetry }: { onLogin: () => void; disabled: boolean; errorMessage: string | null; onRetry?: () => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button
|
||||
className="border-none rounded cursor-pointer flex items-center justify-center gap-2"
|
||||
style={{ fontSize: 13, fontWeight: 500, padding: '8px 16px', background: 'var(--foreground)', color: 'var(--background)', height: 36 }}
|
||||
onClick={onLogin}
|
||||
disabled={disabled}
|
||||
data-testid="github-login"
|
||||
>
|
||||
<GithubLogo size={16} weight="fill" />
|
||||
Login with GitHub
|
||||
</button>
|
||||
{errorMessage && (
|
||||
<div className="flex items-center gap-2" style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)' }}>
|
||||
<span data-testid="github-error">{errorMessage}</span>
|
||||
{onRetry && (
|
||||
<button
|
||||
className="border-none bg-transparent cursor-pointer hover:text-foreground flex items-center gap-1"
|
||||
style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)', padding: 0 }}
|
||||
onClick={onRetry}
|
||||
data-testid="github-retry"
|
||||
>
|
||||
<ArrowClockwise size={12} />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,9 @@ vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn(),
|
||||
}))
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
import { mockInvoke } from '../mock-tauri'
|
||||
const mockInvokeFn = vi.mocked(mockInvoke)
|
||||
@@ -20,6 +23,7 @@ describe('GitHubVaultModal', () => {
|
||||
const onClose = vi.fn()
|
||||
const onVaultCloned = vi.fn()
|
||||
const onOpenSettings = vi.fn()
|
||||
const onGitHubConnected = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -38,7 +42,7 @@ describe('GitHubVaultModal', () => {
|
||||
expect(container.querySelector('[data-testid="github-vault-modal"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows connect prompt when no GitHub token', () => {
|
||||
it('shows settings fallback when no token and no onGitHubConnected', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
)
|
||||
@@ -54,6 +58,37 @@ describe('GitHubVaultModal', () => {
|
||||
expect(onOpenSettings).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows inline device flow when no token and onGitHubConnected is provided', () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} onGitHubConnected={onGitHubConnected} />
|
||||
)
|
||||
expect(screen.getByTestId('github-login')).toBeInTheDocument()
|
||||
expect(screen.getByText('Login with GitHub')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('github-open-settings')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows device code after starting inline OAuth flow', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_device_flow_start') {
|
||||
return { device_code: 'dc_modal', user_code: 'MODAL-5678', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 }
|
||||
}
|
||||
if (cmd === 'github_device_flow_poll') {
|
||||
return { status: 'pending', access_token: null, error: 'authorization_pending' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} onGitHubConnected={onGitHubConnected} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('github-user-code')).toHaveTextContent('MODAL-5678')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows clone and create tabs when token is present', async () => {
|
||||
render(
|
||||
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
|
||||
import type { GithubRepo } from '../types'
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
@@ -18,6 +19,7 @@ interface GitHubVaultModalProps {
|
||||
onClose: () => void
|
||||
onVaultCloned: (path: string, label: string) => void
|
||||
onOpenSettings: () => void
|
||||
onGitHubConnected?: (token: string, username: string) => void
|
||||
}
|
||||
|
||||
type CloneStatus = 'idle' | 'cloning' | 'success' | 'error'
|
||||
@@ -244,7 +246,7 @@ function CloningProgress({ repoName }: { repoName: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings }: GitHubVaultModalProps) {
|
||||
export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings, onGitHubConnected }: GitHubVaultModalProps) {
|
||||
const [tab, setTab] = useState('clone')
|
||||
const [repos, setRepos] = useState<GithubRepo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -365,15 +367,21 @@ export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, on
|
||||
<DialogDescription>Connect your GitHub account to clone or create vaults backed by GitHub repos.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
You need to connect your GitHub account first. Add your GitHub token in Settings.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => { handleClose(); onOpenSettings() }}
|
||||
data-testid="github-open-settings"
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
{onGitHubConnected ? (
|
||||
<GitHubDeviceFlow onConnected={onGitHubConnected} />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
You need to connect your GitHub account first. Add your GitHub token in Settings.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => { handleClose(); onOpenSettings() }}
|
||||
data-testid="github-open-settings"
|
||||
>
|
||||
Open Settings
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -26,6 +26,7 @@ const mockEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -70,6 +71,7 @@ const referrerEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: ['Test Project'],
|
||||
}
|
||||
|
||||
@@ -186,8 +188,11 @@ This is a test note with some words to count.
|
||||
entries={[mockEntry, referrerEntry]}
|
||||
/>
|
||||
)
|
||||
// Backlinks section is collapsed by default, but header with count is visible
|
||||
expect(screen.getByText('Backlinks (1)')).toBeInTheDocument()
|
||||
// Expand to see the backlink entry
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('1')).toBeInTheDocument() // count badge
|
||||
})
|
||||
|
||||
it('updates backlinks reactively when outgoingLinks changes', () => {
|
||||
@@ -200,7 +205,7 @@ This is a test note with some words to count.
|
||||
/>
|
||||
)
|
||||
// Initially no backlinks — section is hidden entirely
|
||||
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
|
||||
|
||||
// Rerender with updated outgoingLinks (simulates adding [[Test Project]] to referrer)
|
||||
rerender(
|
||||
@@ -211,6 +216,8 @@ This is a test note with some words to count.
|
||||
entries={[mockEntry, { ...referrerEntry, outgoingLinks: ['Test Project'] }]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Backlinks (1)')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -223,8 +230,7 @@ This is a test note with some words to count.
|
||||
entries={[mockEntry]}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('No backlinks')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates when a backlink is clicked', () => {
|
||||
@@ -235,10 +241,10 @@ This is a test note with some words to count.
|
||||
entry={mockEntry}
|
||||
content={mockContent}
|
||||
entries={[mockEntry, referrerEntry]}
|
||||
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
fireEvent.click(screen.getByText('Referrer Note'))
|
||||
expect(onNavigate).toHaveBeenCalledWith('Referrer Note')
|
||||
})
|
||||
@@ -370,6 +376,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -396,6 +403,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -422,6 +430,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -448,6 +457,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -485,25 +495,8 @@ Status: Active
|
||||
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/via Belongs to/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/via Related to/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows count badge for referenced-by entries', () => {
|
||||
render(
|
||||
<Inspector
|
||||
{...defaultProps}
|
||||
entry={targetEntry}
|
||||
content={targetContent}
|
||||
entries={[targetEntry, essayEntry, procedureEntry]}
|
||||
|
||||
/>
|
||||
)
|
||||
// 2 entries reference via Belongs to — badge appears in the Referenced by header
|
||||
const allTwos = screen.getAllByText('2')
|
||||
expect(allTwos.length).toBeGreaterThanOrEqual(1)
|
||||
// At least one "2" is inside a badge (span with ml-1 class)
|
||||
expect(allTwos.some(el => el.classList.contains('ml-1'))).toBe(true)
|
||||
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/← Related to/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides referenced-by section when no entries reference the current note', () => {
|
||||
@@ -580,7 +573,7 @@ Status: Active
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
|
||||
expect(screen.getByText(/via Topics/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/← Topics/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excludes entries from backlinks when already shown in referenced-by', () => {
|
||||
@@ -607,6 +600,7 @@ Status: Active
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
// Body text also links to grow-newsletter
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
}
|
||||
@@ -619,10 +613,10 @@ Status: Active
|
||||
/>
|
||||
)
|
||||
// noteA shows in Referenced By (via Belongs to)
|
||||
expect(screen.getByText(/via Belongs to/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
|
||||
// But NOT in Backlinks (even though outgoingLinks matches) — section hidden
|
||||
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show self-references', () => {
|
||||
|
||||
@@ -7,7 +7,8 @@ import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
|
||||
import { wikilinkTarget } from '../utils/wikilink'
|
||||
import type { ReferencedByItem } from './InspectorPanels'
|
||||
import { extractBacklinkContext } from '../utils/wikilinks'
|
||||
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
|
||||
|
||||
export type FrontmatterValue = string | number | boolean | string[] | null
|
||||
|
||||
@@ -17,6 +18,7 @@ interface InspectorProps {
|
||||
entry: VaultEntry | null
|
||||
content: string | null
|
||||
entries: VaultEntry[]
|
||||
allContent?: Record<string, string>
|
||||
gitHistory: GitCommit[]
|
||||
onNavigate: (target: string) => void
|
||||
onViewCommitDiff?: (commitHash: string) => void
|
||||
@@ -25,7 +27,12 @@ interface InspectorProps {
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
}
|
||||
|
||||
function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], referencedBy: ReferencedByItem[]): VaultEntry[] {
|
||||
function useBacklinks(
|
||||
entry: VaultEntry | null,
|
||||
entries: VaultEntry[],
|
||||
referencedBy: ReferencedByItem[],
|
||||
allContent?: Record<string, string>,
|
||||
): BacklinkItem[] {
|
||||
return useMemo(() => {
|
||||
if (!entry) return []
|
||||
const matchTargets = new Set([
|
||||
@@ -36,14 +43,21 @@ function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], reference
|
||||
|
||||
const referencedByPaths = new Set(referencedBy.map((item) => item.entry.path))
|
||||
|
||||
return entries.filter((e) => {
|
||||
if (e.path === entry.path) return false
|
||||
if (referencedByPaths.has(e.path)) return false
|
||||
return e.outgoingLinks.some((target) =>
|
||||
matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')
|
||||
)
|
||||
})
|
||||
}, [entry, entries, referencedBy])
|
||||
return entries
|
||||
.filter((e) => {
|
||||
if (e.path === entry.path) return false
|
||||
if (referencedByPaths.has(e.path)) return false
|
||||
return e.outgoingLinks.some((target) =>
|
||||
matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')
|
||||
)
|
||||
})
|
||||
.map((e) => ({
|
||||
entry: e,
|
||||
context: allContent?.[e.path]
|
||||
? extractBacklinkContext(allContent[e.path], matchTargets)
|
||||
: null,
|
||||
}))
|
||||
}, [entry, entries, referencedBy, allContent])
|
||||
}
|
||||
|
||||
function refsMatchTargets(refs: string[], targets: Set<string>): boolean {
|
||||
@@ -79,7 +93,7 @@ function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[]): Refer
|
||||
function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
|
||||
const { onMouseDown } = useDragRegion()
|
||||
return (
|
||||
<div className="flex shrink-0 items-center border-b border-border" style={{ height: 52, padding: '0 12px', gap: 8, cursor: 'default' }} onMouseDown={onMouseDown}>
|
||||
<div className="flex shrink-0 items-center border-b border-border" style={{ height: 45, padding: '6px 12px', gap: 8, cursor: 'default' }} onMouseDown={onMouseDown}>
|
||||
{collapsed ? (
|
||||
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" onClick={onToggle}>
|
||||
<SlidersHorizontal size={16} />
|
||||
@@ -104,11 +118,11 @@ function EmptyInspector() {
|
||||
}
|
||||
|
||||
export function Inspector({
|
||||
collapsed, onToggle, entry, content, entries, gitHistory, onNavigate,
|
||||
collapsed, onToggle, entry, content, entries, allContent, gitHistory, onNavigate,
|
||||
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
}: InspectorProps) {
|
||||
const referencedBy = useReferencedBy(entry, entries)
|
||||
const backlinks = useBacklinks(entry, entries, referencedBy)
|
||||
const backlinks = useBacklinks(entry, entries, referencedBy, allContent)
|
||||
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
|
||||
const typeEntryMap = useMemo(() => {
|
||||
const map: Record<string, VaultEntry> = {}
|
||||
@@ -137,7 +151,7 @@ export function Inspector({
|
||||
<>
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry} content={content} frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
entries={entries} allContent={allContent}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
|
||||
@@ -30,6 +30,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -77,7 +78,7 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)
|
||||
const chip = container.querySelector('.group\\/link button')
|
||||
const chip = container.querySelector('.group\\/link')
|
||||
expect(chip).toBeTruthy()
|
||||
expect(chip!.style.color).toBe(expectedColor)
|
||||
})
|
||||
@@ -420,27 +421,48 @@ describe('BacklinksPanel', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders backlink entries', () => {
|
||||
const backlinks = [
|
||||
makeEntry({ title: 'Referencing Note', isA: 'Note' }),
|
||||
makeEntry({ title: 'Another Note', isA: 'Project', path: '/vault/project/another.md' }),
|
||||
]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
const twoBacklinks = [
|
||||
{ entry: makeEntry({ title: 'Referencing Note', isA: 'Note' }), context: null },
|
||||
{ entry: makeEntry({ title: 'Another Note', isA: 'Project', path: '/vault/project/another.md' }), context: null },
|
||||
]
|
||||
|
||||
it('renders collapsed by default with count badge', () => {
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={twoBacklinks} onNavigate={onNavigate} />)
|
||||
expect(screen.getByText('Backlinks (2)')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Referencing Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands to show backlink entries when toggle clicked', () => {
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={twoBacklinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('Referencing Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('Another Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates when clicking backlink', () => {
|
||||
const backlinks = [makeEntry({ title: 'Reference' })]
|
||||
const backlinks = [{ entry: makeEntry({ title: 'Reference' }), context: null }]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
fireEvent.click(screen.getByText('Reference'))
|
||||
expect(onNavigate).toHaveBeenCalledWith('Reference')
|
||||
})
|
||||
|
||||
it('shows count when backlinks exist', () => {
|
||||
const backlinks = [makeEntry(), makeEntry({ path: '/vault/b.md', title: 'B' })]
|
||||
it('shows paragraph context preview when available', () => {
|
||||
const backlinks = [
|
||||
{ entry: makeEntry({ title: 'Referencing Note' }), context: 'This references [[My Note]] in context.' },
|
||||
]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('This references [[My Note]] in context.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses when toggle clicked twice', () => {
|
||||
const backlinks = [{ entry: makeEntry({ title: 'Note A' }), context: null }]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('Note A')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.queryByText('Note A')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -467,18 +489,8 @@ describe('ReferencedByPanel', () => {
|
||||
expect(screen.getByText('Write Essays')).toBeInTheDocument()
|
||||
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
|
||||
expect(screen.getByText('SEO Experiment')).toBeInTheDocument()
|
||||
expect(screen.getByText(/via Belongs to/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/via Related to/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows count badge when items exist', () => {
|
||||
const items: ReferencedByItem[] = [
|
||||
{ entry: makeEntry({ path: '/vault/a.md', title: 'A' }), viaKey: 'Has' },
|
||||
{ entry: makeEntry({ path: '/vault/b.md', title: 'B' }), viaKey: 'Has' },
|
||||
{ entry: makeEntry({ path: '/vault/c.md', title: 'C' }), viaKey: 'Topics' },
|
||||
]
|
||||
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/← Related to/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates when clicking a referenced-by entry', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useCallback, useState, useRef } from 'react'
|
||||
import type { ComponentType, SVGAttributes } from 'react'
|
||||
import { wikilinkTarget, wikilinkDisplay } from '../utils/wikilink'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import { Trash, X, Plus } from '@phosphor-icons/react'
|
||||
import { CaretRight, Trash, X } from '@phosphor-icons/react'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
import { RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
@@ -48,35 +48,36 @@ function LinkButton({ label, typeColor, bgColor, isArchived, isTrashed, onClick,
|
||||
const isDimmed = isArchived || isTrashed
|
||||
const color = isDimmed ? 'var(--muted-foreground)' : typeColor
|
||||
return (
|
||||
<div className="group/link flex items-center gap-1">
|
||||
<button
|
||||
className="flex flex-1 items-center justify-between gap-2 border-none text-left cursor-pointer hover:opacity-80 min-w-0"
|
||||
style={{
|
||||
background: isDimmed ? 'var(--muted)' : (bgColor ?? 'transparent'),
|
||||
color, borderRadius: 6, padding: bgColor ? '6px 10px' : '4px 0',
|
||||
fontSize: 12, fontWeight: 500, opacity: isDimmed ? 0.7 : 1,
|
||||
}}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
>
|
||||
<span className="flex items-center gap-1 flex-1 truncate">
|
||||
{isTrashed && <Trash size={12} className="shrink-0" />}
|
||||
{label}
|
||||
<StatusSuffix isArchived={isArchived} isTrashed={isTrashed} />
|
||||
</span>
|
||||
<TypeIcon width={14} height={14} className="shrink-0" style={{ color }} />
|
||||
</button>
|
||||
{onRemove && (
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover/link:opacity-100"
|
||||
onClick={onRemove}
|
||||
title="Remove from relation"
|
||||
data-testid="remove-relation-ref"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`group/link flex w-full items-center justify-between gap-2 border-none text-left cursor-pointer min-w-0${bgColor ? ' ring-inset hover:ring-1 hover:ring-current' : ' hover:opacity-80'}`}
|
||||
style={{
|
||||
background: isDimmed ? 'var(--muted)' : (bgColor ?? 'transparent'),
|
||||
color, borderRadius: 6, padding: bgColor ? '6px 10px' : '4px 0',
|
||||
fontSize: 12, fontWeight: 500, opacity: isDimmed ? 0.7 : 1,
|
||||
}}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
>
|
||||
<span className="flex items-center gap-1 flex-1 truncate">
|
||||
{isTrashed && <Trash size={12} className="shrink-0" />}
|
||||
{label}
|
||||
<StatusSuffix isArchived={isArchived} isTrashed={isTrashed} />
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
{onRemove && (
|
||||
<span
|
||||
className="flex items-center opacity-0 transition-opacity group-hover/link:opacity-100"
|
||||
onClick={(e) => { e.stopPropagation(); onRemove() }}
|
||||
role="button"
|
||||
title="Remove from relation"
|
||||
data-testid="remove-relation-ref"
|
||||
>
|
||||
<X size={14} />
|
||||
</span>
|
||||
)}
|
||||
<TypeIcon width={14} height={14} className="shrink-0" style={{ color, opacity: 0.5 }} />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -149,25 +150,24 @@ function InlineAddNote({ entries, onAdd }: {
|
||||
if (!active) {
|
||||
return (
|
||||
<button
|
||||
className="mt-1 flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
style={{ fontSize: 11 }}
|
||||
className="mt-1 w-full border border-dashed border-border bg-transparent text-left text-muted-foreground cursor-pointer hover:border-foreground hover:text-foreground"
|
||||
style={{ borderRadius: 6, padding: '6px 10px', fontSize: 12 }}
|
||||
onClick={() => setActive(true)}
|
||||
data-testid="add-relation-ref"
|
||||
>
|
||||
<Plus size={10} />
|
||||
<span>Add</span>
|
||||
Add
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mt-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="group/add relative flex items-center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
className="flex-1 border border-border bg-transparent px-2 py-0.5 text-xs text-foreground"
|
||||
style={{ borderRadius: 4, outline: 'none', minWidth: 0 }}
|
||||
className="w-full border border-border bg-transparent text-foreground"
|
||||
style={{ borderRadius: 6, outline: 'none', minWidth: 0, padding: '6px 10px', fontSize: 12 }}
|
||||
placeholder="Note title"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
@@ -175,14 +175,7 @@ function InlineAddNote({ entries, onAdd }: {
|
||||
data-testid="add-relation-ref-input"
|
||||
/>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleConfirm}
|
||||
disabled={!query.trim()}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground hover:text-foreground"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 border-none bg-transparent p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/add:opacity-100"
|
||||
onClick={() => { setQuery(''); setActive(false) }}
|
||||
>
|
||||
<X size={12} />
|
||||
@@ -379,21 +372,78 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
|
||||
)
|
||||
}
|
||||
|
||||
export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: { backlinks: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>; onNavigate: (target: string) => void }) {
|
||||
export interface BacklinkItem {
|
||||
entry: VaultEntry
|
||||
context: string | null
|
||||
}
|
||||
|
||||
function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: {
|
||||
entry: VaultEntry
|
||||
context: string | null
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
const isDimmed = entry.archived || entry.trashed
|
||||
return (
|
||||
<button
|
||||
className="flex w-full cursor-pointer flex-col items-start gap-0.5 border-none bg-transparent p-0 text-left hover:opacity-80"
|
||||
onClick={() => onNavigate(entry.title)}
|
||||
title={entryStatusTitle(entry)}
|
||||
>
|
||||
<span
|
||||
className="flex items-center gap-1 text-xs font-medium"
|
||||
style={{ color: isDimmed ? 'var(--muted-foreground)' : getTypeColor(entry.isA, te?.color) }}
|
||||
>
|
||||
{entry.trashed && <Trash size={12} className="shrink-0" />}
|
||||
{entry.title}
|
||||
<StatusSuffix isArchived={entry.archived} isTrashed={entry.trashed} />
|
||||
</span>
|
||||
{context && (
|
||||
<span className="line-clamp-2 text-[11px] leading-snug text-muted-foreground">
|
||||
{context}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: {
|
||||
backlinks: BacklinkItem[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
if (backlinks.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="font-mono-overline mb-2 text-muted-foreground">
|
||||
Backlinks <span className="ml-1" style={{ fontWeight: 400 }}>{backlinks.length}</span>
|
||||
</h4>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{backlinks.map((e) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return (
|
||||
<LinkButton key={e.path} label={e.title} typeColor={getTypeColor(e.isA, te?.color)} isArchived={e.archived} isTrashed={e.trashed} onClick={() => onNavigate(e.title)} title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined} TypeIcon={getTypeIcon(e.isA, te?.icon)} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className="font-mono-overline mb-2 flex w-full cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
data-testid="backlinks-toggle"
|
||||
>
|
||||
<CaretRight
|
||||
size={12}
|
||||
className="shrink-0 transition-transform"
|
||||
style={{ transform: expanded ? 'rotate(90deg)' : undefined }}
|
||||
/>
|
||||
Backlinks ({backlinks.length})
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-1.5" data-testid="backlinks-list">
|
||||
{backlinks.map(({ entry, context }) => (
|
||||
<BacklinkEntry
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
context={context}
|
||||
typeEntryMap={typeEntryMap}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -421,15 +471,12 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="font-mono-overline mb-2 text-muted-foreground">
|
||||
Referenced by <span className="ml-1" style={{ fontWeight: 400 }}>{items.length}</span>
|
||||
</h4>
|
||||
<div className="referenced-by-panel">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{grouped.map(([viaKey, groupEntries]) => (
|
||||
<div key={viaKey}>
|
||||
<span className="mb-1 block font-mono text-muted-foreground" style={{ fontSize: 9, fontWeight: 600, letterSpacing: '1.2px', textTransform: 'uppercase', opacity: 0.7 }}>
|
||||
via {viaKey}
|
||||
<span className="mb-1 block font-mono text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '1.2px', textTransform: 'uppercase', opacity: 0.7 }}>
|
||||
← {viaKey}
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{groupEntries.map((e) => {
|
||||
|
||||
@@ -37,7 +37,7 @@ function matchEntries(entries: VaultEntry[], typeEntryMap: Record<string, VaultE
|
||||
return matches.slice(0, MAX_RESULTS).map(e => {
|
||||
const isA = e.isA
|
||||
const te = typeEntryMap[isA ?? '']
|
||||
const noteType = isA && isA !== 'Note' ? isA : undefined
|
||||
const noteType = isA || undefined
|
||||
return {
|
||||
title: e.title,
|
||||
noteType,
|
||||
|
||||
@@ -52,10 +52,49 @@ const NOTE_STATUS_DOT: Record<string, { color: string; testId: string; title: st
|
||||
modified: { color: 'var(--accent-orange)', testId: 'modified-indicator', title: 'Modified (uncommitted)' },
|
||||
}
|
||||
|
||||
export function NoteItem({ entry, isSelected, isMultiSelected = false, noteStatus = 'clean', typeEntryMap, onClickNote }: {
|
||||
function StatusDot({ noteStatus }: { noteStatus: NoteStatus }) {
|
||||
const dot = NOTE_STATUS_DOT[noteStatus]
|
||||
if (!dot) return null
|
||||
return (
|
||||
<span
|
||||
className={`mr-1.5 inline-block align-middle${noteStatus === 'pendingSave' ? ' tab-status-pulse' : ''}`}
|
||||
style={{ width: 6, height: 6, borderRadius: '50%', background: dot.color, verticalAlign: 'middle' }}
|
||||
data-testid={dot.testId}
|
||||
title={dot.title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function StateBadge({ archived, trashed }: { archived: boolean; trashed: boolean }) {
|
||||
if (archived) {
|
||||
return (
|
||||
<span className="ml-1.5 inline-block align-middle text-muted-foreground" style={{ fontSize: 9, fontWeight: 500, background: 'var(--muted)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>
|
||||
ARCHIVED
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (trashed) {
|
||||
return (
|
||||
<span className="ml-1.5 inline-block align-middle" style={{ fontSize: 9, fontWeight: 500, background: 'var(--destructive-light, #ef44441a)', color: 'var(--destructive)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>
|
||||
TRASHED
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor: string, typeLightColor: string): React.CSSProperties {
|
||||
const base: React.CSSProperties = { padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px' }
|
||||
if (isMultiSelected) base.backgroundColor = 'color-mix(in srgb, var(--accent-blue) 10%, transparent)'
|
||||
else if (isSelected) { base.borderLeftColor = typeColor; base.backgroundColor = typeLightColor }
|
||||
return base
|
||||
}
|
||||
|
||||
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', typeEntryMap, onClickNote }: {
|
||||
entry: VaultEntry
|
||||
isSelected: boolean
|
||||
isMultiSelected?: boolean
|
||||
isHighlighted?: boolean
|
||||
noteStatus?: NoteStatus
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
@@ -70,39 +109,21 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, noteStatu
|
||||
className={cn(
|
||||
"relative cursor-pointer border-b border-[var(--border)] transition-colors",
|
||||
isSelected && !isMultiSelected && "border-l-[3px]",
|
||||
!isSelected && !isMultiSelected && "hover:bg-muted"
|
||||
!isSelected && !isMultiSelected && "hover:bg-muted",
|
||||
isHighlighted && !isSelected && !isMultiSelected && "bg-muted"
|
||||
)}
|
||||
style={{
|
||||
padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px',
|
||||
...(isMultiSelected && { backgroundColor: 'color-mix(in srgb, var(--accent-blue) 10%, transparent)' }),
|
||||
...(isSelected && !isMultiSelected && { borderLeftColor: typeColor, backgroundColor: typeLightColor }),
|
||||
}}
|
||||
style={noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)}
|
||||
onClick={(e: React.MouseEvent) => onClickNote(entry, e)}
|
||||
data-testid={isMultiSelected ? 'multi-selected-item' : undefined}
|
||||
data-highlighted={isHighlighted || undefined}
|
||||
>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
|
||||
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
|
||||
<div className="pr-5">
|
||||
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>
|
||||
{noteStatus !== 'clean' && NOTE_STATUS_DOT[noteStatus] && (
|
||||
<span
|
||||
className={`mr-1.5 inline-block align-middle${noteStatus === 'pendingSave' ? ' tab-status-pulse' : ''}`}
|
||||
style={{ width: 6, height: 6, borderRadius: '50%', background: NOTE_STATUS_DOT[noteStatus].color, verticalAlign: 'middle' }}
|
||||
data-testid={NOTE_STATUS_DOT[noteStatus].testId}
|
||||
title={NOTE_STATUS_DOT[noteStatus].title}
|
||||
/>
|
||||
)}
|
||||
{noteStatus !== 'clean' && <StatusDot noteStatus={noteStatus} />}
|
||||
{entry.title}
|
||||
{entry.archived && (
|
||||
<span className="ml-1.5 inline-block align-middle text-muted-foreground" style={{ fontSize: 9, fontWeight: 500, background: 'var(--muted)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>
|
||||
ARCHIVED
|
||||
</span>
|
||||
)}
|
||||
{entry.trashed && (
|
||||
<span className="ml-1.5 inline-block align-middle" style={{ fontSize: 9, fontWeight: 500, background: 'var(--destructive-light, #ef44441a)', color: 'var(--destructive)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>
|
||||
TRASHED
|
||||
</span>
|
||||
)}
|
||||
<StateBadge archived={entry.archived} trashed={entry.trashed} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
|
||||
@@ -34,6 +34,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -62,6 +63,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -87,6 +89,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -112,6 +115,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -137,6 +141,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
@@ -288,27 +293,27 @@ describe('NoteList click behavior', () => {
|
||||
expect(noopSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Click toggles multi-select on a note', () => {
|
||||
it('Cmd+Click calls onSelectNote (opens in new tab)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('multi-selected-item')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Ctrl+Click toggles multi-select (Windows/Linux)', () => {
|
||||
it('Ctrl+Click calls onSelectNote (Windows/Linux)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('multi-selected-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Cmd+Click on entity pinned card toggles multi-select', () => {
|
||||
it('Cmd+Click on entity pinned card calls onSelectNote', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const titles = screen.getAllByText('Build Laputa App')
|
||||
fireEvent.click(titles[titles.length - 1], { metaKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -357,6 +362,7 @@ describe('getSortComparator', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -469,6 +475,7 @@ describe('NoteList sort controls', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -663,6 +670,7 @@ const trashedEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -689,6 +697,7 @@ const expiredTrashedEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -844,6 +853,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -985,6 +995,19 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows untracked (new) notes alongside modified notes in changes view', () => {
|
||||
const mixedFiles = [
|
||||
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
|
||||
{ path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const },
|
||||
]
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -996,40 +1019,10 @@ describe('NoteList — multi-select', () => {
|
||||
noopReplace.mockClear()
|
||||
})
|
||||
|
||||
it('Cmd+Click selects multiple notes', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { metaKey: true })
|
||||
const selected = screen.getAllByTestId('multi-selected-item')
|
||||
expect(selected).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('Cmd+Click toggles off an already-selected note', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
expect(screen.getAllByTestId('multi-selected-item')).toHaveLength(1)
|
||||
// Toggle off
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('regular click clears multi-select and opens note', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
// Multi-select two notes
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { metaKey: true })
|
||||
expect(screen.getAllByTestId('multi-selected-item')).toHaveLength(2)
|
||||
// Regular click clears selection and opens note
|
||||
fireEvent.click(screen.getByText('Matteo Cellini'))
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
expect(noopReplace).toHaveBeenCalledWith(mockEntries[2])
|
||||
})
|
||||
|
||||
it('Shift+Click selects a range of notes', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
// First Cmd+Click to set anchor
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
// Regular click to set anchor
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
// Shift+Click to select range
|
||||
fireEvent.click(screen.getByText('Matteo Cellini'), { shiftKey: true })
|
||||
// Should select all notes between Build Laputa App and Matteo Cellini (inclusive)
|
||||
@@ -1037,10 +1030,34 @@ describe('NoteList — multi-select', () => {
|
||||
expect(selected.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('regular click clears multi-select and opens note', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
// Select range via Shift+click
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getAllByTestId('multi-selected-item').length).toBeGreaterThanOrEqual(1)
|
||||
// Regular click clears selection and opens note
|
||||
fireEvent.click(screen.getByText('Matteo Cellini'))
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
expect(noopReplace).toHaveBeenCalledWith(mockEntries[2])
|
||||
})
|
||||
|
||||
it('Cmd+Click clears multi-select and opens in new tab', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
// Select range via Shift+click
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getAllByTestId('multi-selected-item').length).toBeGreaterThanOrEqual(1)
|
||||
// Cmd+click clears selection and opens in new tab
|
||||
fireEvent.click(screen.getByText('Matteo Cellini'), { metaKey: true })
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[2])
|
||||
})
|
||||
|
||||
it('shows bulk action bar with correct count', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { metaKey: true })
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument()
|
||||
})
|
||||
@@ -1048,32 +1065,160 @@ describe('NoteList — multi-select', () => {
|
||||
it('bulk archive calls onBulkArchive and clears selection', () => {
|
||||
const onBulkArchive = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.click(screen.getByTestId('bulk-archive-btn'))
|
||||
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path])
|
||||
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('bulk trash calls onBulkTrash and clears selection', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.click(screen.getByTestId('bulk-trash-btn'))
|
||||
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path])
|
||||
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clear button on bulk action bar clears selection', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('bulk-clear-btn'))
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Cmd+E archives selected notes when multiselect is active', () => {
|
||||
const onBulkArchive = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
fireEvent.keyDown(window, { key: 'e', metaKey: true })
|
||||
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Cmd+Backspace trashes selected notes when multiselect is active', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
fireEvent.keyDown(window, { key: 'Backspace', metaKey: true })
|
||||
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Cmd+Delete trashes selected notes when multiselect is active', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.keyDown(window, { key: 'Delete', metaKey: true })
|
||||
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('no bulk action bar when nothing is selected', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --- Type note filtering tests ---
|
||||
|
||||
const typeEntry: VaultEntry = {
|
||||
path: '/Users/luca/Laputa/types/project.md',
|
||||
filename: 'project.md',
|
||||
title: 'Project',
|
||||
isA: 'Type',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
snippet: 'Defines the Project type.',
|
||||
wordCount: 50,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
const entriesWithType = [...mockEntries, typeEntry]
|
||||
|
||||
describe('NoteList — type note filtering', () => {
|
||||
beforeEach(() => {
|
||||
noopSelect.mockClear()
|
||||
noopReplace.mockClear()
|
||||
})
|
||||
|
||||
it('does not show type note PinnedCard when browsing a sectionGroup', () => {
|
||||
render(
|
||||
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// The type note snippet should NOT be visible (PinnedCard was removed)
|
||||
expect(screen.queryByText('Defines the Project type.')).not.toBeInTheDocument()
|
||||
// But the Project instance should still be in the list
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows clickable header title that navigates to type note', () => {
|
||||
render(
|
||||
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const headerLink = screen.getByTestId('type-header-link')
|
||||
expect(headerLink).toBeInTheDocument()
|
||||
expect(headerLink.textContent).toBe('Project')
|
||||
expect(headerLink.style.cursor).toBe('pointer')
|
||||
fireEvent.click(headerLink)
|
||||
expect(noopReplace).toHaveBeenCalledWith(typeEntry)
|
||||
})
|
||||
|
||||
it('header is not clickable when not viewing a type section', () => {
|
||||
render(
|
||||
<NoteList entries={entriesWithType} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('type-header-link')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteList — traffic light padding when sidebar collapsed', () => {
|
||||
it('adds left padding to header when sidebarCollapsed is true', () => {
|
||||
const { container } = render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} sidebarCollapsed={true} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
||||
expect(header.style.paddingLeft).toBe('80px')
|
||||
})
|
||||
|
||||
it('does not add extra left padding when sidebarCollapsed is false', () => {
|
||||
const { container } = render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} sidebarCollapsed={false} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
||||
expect(header.style.paddingLeft).toBe('')
|
||||
})
|
||||
|
||||
it('does not add extra left padding when sidebarCollapsed is not provided', () => {
|
||||
const { container } = render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
||||
expect(header.style.paddingLeft).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
|
||||
import { useDragRegion } from '../hooks/useDragRegion'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ import { NoteItem, getTypeIcon } from './NoteItem'
|
||||
import { SortDropdown } from './SortDropdown'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
import { useMultiSelect } from '../hooks/useMultiSelect'
|
||||
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
|
||||
import {
|
||||
type SortOption, type SortDirection, type SortConfig, type RelationshipGroup,
|
||||
getSortComparator,
|
||||
@@ -26,6 +27,7 @@ interface NoteListProps {
|
||||
allContent: Record<string, string>
|
||||
modifiedFiles?: ModifiedFile[]
|
||||
getNoteStatus?: (path: string) => NoteStatus
|
||||
sidebarCollapsed?: boolean
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onCreateNote: () => void
|
||||
@@ -135,31 +137,25 @@ function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggl
|
||||
)
|
||||
}
|
||||
|
||||
function ListViewHeader({ typeDocument, isTrashView, expiredTrashCount, typeEntryMap, onClickNote }: {
|
||||
typeDocument: VaultEntry | null; isTrashView: boolean; expiredTrashCount: number
|
||||
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
function ListViewHeader({ isTrashView, expiredTrashCount }: {
|
||||
isTrashView: boolean; expiredTrashCount: number
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{typeDocument && <PinnedCard entry={typeDocument} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />}
|
||||
<TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
|
||||
</>
|
||||
)
|
||||
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
|
||||
}
|
||||
|
||||
function ListView({ typeDocument, isTrashView, isChangesView, expiredTrashCount, searched, query, renderItem, typeEntryMap, onClickNote }: {
|
||||
typeDocument: VaultEntry | null; isTrashView: boolean; isChangesView?: boolean; expiredTrashCount: number
|
||||
function ListView({ isTrashView, isChangesView, expiredTrashCount, searched, query, renderItem, virtuosoRef }: {
|
||||
isTrashView: boolean; isChangesView?: boolean; expiredTrashCount: number
|
||||
searched: VaultEntry[]; query: string
|
||||
renderItem: (entry: VaultEntry) => React.ReactNode
|
||||
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
|
||||
}) {
|
||||
const emptyText = isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
|
||||
const hasHeader = typeDocument || (isTrashView && expiredTrashCount > 0)
|
||||
const hasHeader = isTrashView && expiredTrashCount > 0
|
||||
|
||||
if (searched.length === 0) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
{hasHeader && <ListViewHeader typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />}
|
||||
{hasHeader && <ListViewHeader isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} />}
|
||||
<EmptyMessage text={emptyText} />
|
||||
</div>
|
||||
)
|
||||
@@ -167,11 +163,12 @@ function ListView({ typeDocument, isTrashView, isChangesView, expiredTrashCount,
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
style={{ height: '100%' }}
|
||||
data={searched}
|
||||
overscan={200}
|
||||
components={{
|
||||
Header: hasHeader ? () => <ListViewHeader typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} typeEntryMap={typeEntryMap} onClickNote={onClickNote} /> : undefined,
|
||||
Header: hasHeader ? () => <ListViewHeader isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} /> : undefined,
|
||||
}}
|
||||
itemContent={(_index, entry) => renderItem(entry)}
|
||||
/>
|
||||
@@ -196,16 +193,17 @@ function countExpiredTrash(entries: VaultEntry[]): number {
|
||||
|
||||
// --- Click routing ---
|
||||
|
||||
type MultiSelectActions = { toggle: (path: string) => void; selectRange: (path: string) => void; clear: () => void }
|
||||
type MultiSelectActions = { selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void }
|
||||
|
||||
function routeNoteClick(
|
||||
entry: VaultEntry, e: React.MouseEvent,
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void,
|
||||
onSelectNote: (entry: VaultEntry) => void,
|
||||
multiSelect: MultiSelectActions,
|
||||
) {
|
||||
if (e.shiftKey) { multiSelect.selectRange(entry.path) }
|
||||
else if (e.metaKey || e.ctrlKey) { multiSelect.toggle(entry.path) }
|
||||
else { multiSelect.clear(); onReplaceActiveTab(entry) }
|
||||
else if (e.metaKey || e.ctrlKey) { multiSelect.clear(); onSelectNote(entry) }
|
||||
else { multiSelect.clear(); multiSelect.setAnchor(entry.path); onReplaceActiveTab(entry) }
|
||||
}
|
||||
|
||||
// --- Pure helpers extracted from NoteListInner to reduce cyclomatic complexity ---
|
||||
@@ -275,7 +273,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
|
||||
|
||||
const defaultGetNoteStatus = (): NoteStatus => 'clean'
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
@@ -307,16 +305,36 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
const listDirection = listConfig.direction
|
||||
const { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet })
|
||||
|
||||
const multiSelect = useMultiSelect(searched)
|
||||
const noteListKeyboard = useNoteListKeyboard({
|
||||
items: searched,
|
||||
selectedNotePath: selectedNote?.path ?? null,
|
||||
onOpen: onReplaceActiveTab,
|
||||
enabled: !isEntityView,
|
||||
})
|
||||
|
||||
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
|
||||
|
||||
// Clear multi-select when sidebar selection changes
|
||||
useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only
|
||||
|
||||
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
|
||||
routeNoteClick(entry, e, onReplaceActiveTab, multiSelect)
|
||||
}, [onReplaceActiveTab, multiSelect])
|
||||
routeNoteClick(entry, e, onReplaceActiveTab, onSelectNote, multiSelect)
|
||||
}, [onReplaceActiveTab, onSelectNote, multiSelect])
|
||||
|
||||
// Keyboard: Escape to clear, Cmd+A to select all
|
||||
const handleBulkArchive = useCallback(() => {
|
||||
const paths = [...multiSelect.selectedPaths]
|
||||
multiSelect.clear()
|
||||
onBulkArchive?.(paths)
|
||||
}, [multiSelect, onBulkArchive])
|
||||
|
||||
const handleBulkTrash = useCallback(() => {
|
||||
const paths = [...multiSelect.selectedPaths]
|
||||
multiSelect.clear()
|
||||
onBulkTrash?.(paths)
|
||||
}, [multiSelect, onBulkTrash])
|
||||
|
||||
// Keyboard: Escape to clear, Cmd+A to select all, Cmd+E/Cmd+Delete for bulk actions
|
||||
// Uses capture phase so bulk shortcuts preempt the global single-note handler
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && multiSelect.isMultiSelecting) {
|
||||
@@ -331,31 +349,37 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
multiSelect.selectAll()
|
||||
}
|
||||
}
|
||||
if (multiSelect.isMultiSelecting && (e.metaKey || e.ctrlKey)) {
|
||||
if (e.key === 'e') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleBulkArchive()
|
||||
} else if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleBulkTrash()
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [multiSelect, isEntityView])
|
||||
|
||||
const handleBulkArchive = useCallback(() => {
|
||||
const paths = [...multiSelect.selectedPaths]
|
||||
multiSelect.clear()
|
||||
onBulkArchive?.(paths)
|
||||
}, [multiSelect, onBulkArchive])
|
||||
|
||||
const handleBulkTrash = useCallback(() => {
|
||||
const paths = [...multiSelect.selectedPaths]
|
||||
multiSelect.clear()
|
||||
onBulkTrash?.(paths)
|
||||
}, [multiSelect, onBulkTrash])
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [multiSelect, isEntityView, handleBulkArchive, handleBulkTrash])
|
||||
|
||||
const renderItem = useCallback((entry: VaultEntry) => (
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths])
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
<div className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4" onMouseDown={onDragMouseDown} style={{ cursor: 'default' }}>
|
||||
<h3 className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold">{resolveHeaderTitle(selection, typeDocument)}</h3>
|
||||
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
<div className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4" onMouseDown={onDragMouseDown} style={{ cursor: 'default', paddingLeft: sidebarCollapsed ? 80 : undefined }}>
|
||||
<h3
|
||||
className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold"
|
||||
style={typeDocument ? { cursor: 'pointer' } : undefined}
|
||||
onClick={typeDocument ? () => onReplaceActiveTab(typeDocument) : undefined}
|
||||
data-testid={typeDocument ? 'type-header-link' : undefined}
|
||||
>
|
||||
{resolveHeaderTitle(selection, typeDocument)}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} onChange={handleSortChange} />}
|
||||
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }} title="Search notes">
|
||||
@@ -373,11 +397,11 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
|
||||
<div className="flex-1 overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
|
||||
{isEntityView && selection.kind === 'entity' ? (
|
||||
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
) : (
|
||||
<ListView typeDocument={typeDocument} isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
<ListView isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@ vi.mock('../mock-tauri', () => ({
|
||||
import { mockInvoke } from '../mock-tauri'
|
||||
const mockInvokeFn = vi.mocked(mockInvoke)
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000)
|
||||
|
||||
const MOCK_ENTRIES: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/essay/ai-apis.md',
|
||||
@@ -27,16 +29,17 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000,
|
||||
createdAt: Date.now() / 1000,
|
||||
modifiedAt: NOW - 7200,
|
||||
createdAt: NOW - 86400 * 30,
|
||||
fileSize: 500,
|
||||
snippet: 'A guide to designing APIs for AI',
|
||||
wordCount: 0,
|
||||
wordCount: 1247,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
template: null,
|
||||
outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'],
|
||||
},
|
||||
{
|
||||
path: '/vault/event/retreat.md',
|
||||
@@ -52,16 +55,17 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000,
|
||||
createdAt: Date.now() / 1000,
|
||||
modifiedAt: NOW - 86400 * 5,
|
||||
createdAt: NOW - 86400 * 5,
|
||||
fileSize: 300,
|
||||
snippet: 'Team retreat event',
|
||||
wordCount: 0,
|
||||
wordCount: 856,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
template: null,
|
||||
outgoingLinks: ['person/bob'],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -195,7 +199,7 @@ describe('SearchPanel', () => {
|
||||
expect(screen.getByText('Result One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document, { key: 'ArrowDown' })
|
||||
|
||||
await waitFor(() => {
|
||||
const resultTwo = screen.getByText('Result Two').closest('[class*="cursor-pointer"]')!
|
||||
@@ -270,6 +274,49 @@ describe('SearchPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows metadata subtitle with word count and links', async () => {
|
||||
mockInvokeFn.mockResolvedValue({
|
||||
results: [
|
||||
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'Content', score: 0.9, note_type: null },
|
||||
],
|
||||
elapsed_ms: 20,
|
||||
})
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1,247 words/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/3 links/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('omits links from subtitle when entry has zero outgoing links', async () => {
|
||||
const noLinksEntries = MOCK_ENTRIES.map(e =>
|
||||
e.path === '/vault/essay/ai-apis.md' ? { ...e, outgoingLinks: [] } : e,
|
||||
)
|
||||
mockInvokeFn.mockResolvedValue({
|
||||
results: [
|
||||
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null },
|
||||
],
|
||||
elapsed_ms: 20,
|
||||
})
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={noLinksEntries} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1,247 words/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/links/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading spinner while searching', async () => {
|
||||
const resolvers: ((v: unknown) => void)[] = []
|
||||
mockInvokeFn.mockImplementation(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useRef, useEffect, useCallback } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { SearchResult, VaultEntry } from '../types'
|
||||
import { useUnifiedSearch } from '../hooks/useUnifiedSearch'
|
||||
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { getTypeColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { formatSearchSubtitle } from '../utils/noteListHelpers'
|
||||
import { getTypeIcon } from './NoteItem'
|
||||
|
||||
interface SearchPanelProps {
|
||||
open: boolean
|
||||
@@ -63,8 +64,8 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }:
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const entryLookup = useMemo(() => {
|
||||
const map = new Map<string, { isA: string | null }>()
|
||||
for (const e of entries) map.set(e.path, { isA: e.isA })
|
||||
const map = new Map<string, VaultEntry>()
|
||||
for (const e of entries) map.set(e.path, e)
|
||||
return map
|
||||
}, [entries])
|
||||
|
||||
@@ -148,7 +149,7 @@ interface SearchContentProps {
|
||||
selectedIndex: number
|
||||
loading: boolean
|
||||
elapsedMs: number | null
|
||||
entryLookup: Map<string, { isA: string | null }>
|
||||
entryLookup: Map<string, VaultEntry>
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
onSelect: (result: SearchResult) => void
|
||||
@@ -190,10 +191,13 @@ function SearchContent({
|
||||
</div>
|
||||
<div ref={listRef}>
|
||||
{results.map((result, i) => {
|
||||
const isA = entryLookup.get(result.path)?.isA ?? result.noteType
|
||||
const noteType = isA && isA !== 'Note' ? isA : null
|
||||
const typeColor = noteType ? getTypeColor(isA, typeEntryMap[isA ?? '']?.color) : undefined
|
||||
const typeLightColor = noteType ? getTypeLightColor(isA, typeEntryMap[isA ?? '']?.color) : undefined
|
||||
const entry = entryLookup.get(result.path)
|
||||
const isA = entry?.isA ?? result.noteType
|
||||
const noteType = isA || null
|
||||
const te = typeEntryMap[isA ?? '']
|
||||
const typeColor = noteType ? getTypeColor(isA, te?.color) : undefined
|
||||
const TypeIcon = getTypeIcon(isA ?? null, te?.icon)
|
||||
const subtitle = entry ? formatSearchSubtitle(entry) : null
|
||||
return (
|
||||
<div
|
||||
key={result.path}
|
||||
@@ -205,16 +209,16 @@ function SearchContent({
|
||||
onMouseEnter={() => onHover(i)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-foreground">{result.title}</span>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon from static map lookup */}
|
||||
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: typeColor ?? 'var(--muted-foreground)' }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">{result.title}</span>
|
||||
{noteType && (
|
||||
<Badge variant="secondary" className="text-[10px]" style={typeColor ? { color: typeColor, backgroundColor: typeLightColor } : undefined}>
|
||||
{noteType}
|
||||
</Badge>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground/70">{noteType}</span>
|
||||
)}
|
||||
</div>
|
||||
{result.snippet && (
|
||||
<p className="mt-1 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
|
||||
{result.snippet}
|
||||
{subtitle && (
|
||||
<p className="mt-0.5 pl-[22px] text-[11px] text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { SettingsPanel } from './SettingsPanel'
|
||||
import type { Settings } from '../types'
|
||||
import type { ThemeManager } from '../hooks/useThemeManager'
|
||||
|
||||
// Mock the tauri/mock-tauri calls used by GitHubSection
|
||||
const mockInvokeFn = vi.fn()
|
||||
const mockOpenExternalUrl = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
@@ -12,6 +14,9 @@ vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: (...args: unknown[]) => mockOpenExternalUrl(...args),
|
||||
}))
|
||||
|
||||
const emptySettings: Settings = {
|
||||
anthropic_key: null,
|
||||
@@ -31,6 +36,15 @@ const populatedSettings: Settings = {
|
||||
auto_pull_interval_minutes: 5,
|
||||
}
|
||||
|
||||
const mockThemeManager: ThemeManager = {
|
||||
themes: [],
|
||||
activeThemeId: null,
|
||||
activeTheme: null,
|
||||
switchTheme: vi.fn(),
|
||||
createTheme: vi.fn().mockResolvedValue('untitled'),
|
||||
reloadThemes: vi.fn(),
|
||||
}
|
||||
|
||||
describe('SettingsPanel', () => {
|
||||
const onSave = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
@@ -41,54 +55,51 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('renders nothing when not open', () => {
|
||||
const { container } = render(
|
||||
<SettingsPanel open={false} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={false} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders modal when open', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.getByText('AI Provider Keys')).toBeInTheDocument()
|
||||
expect(screen.getByText(/stored locally/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows three key fields with labels', () => {
|
||||
it('shows two key fields with labels', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
expect(screen.getByText('Anthropic')).toBeInTheDocument()
|
||||
expect(screen.getByText('OpenAI')).toBeInTheDocument()
|
||||
expect(screen.getByText('Google AI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('populates fields from settings', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
const anthropicInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement
|
||||
const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
|
||||
const googleInput = screen.getByTestId('settings-key-google-ai') as HTMLInputElement
|
||||
|
||||
expect(anthropicInput.value).toBe('sk-ant-api03-test123')
|
||||
expect(openaiInput.value).toBe('sk-openai-test456')
|
||||
expect(googleInput.value).toBe('')
|
||||
})
|
||||
|
||||
it('calls onSave with trimmed keys on save', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
const anthropicInput = screen.getByTestId('settings-key-anthropic')
|
||||
fireEvent.change(anthropicInput, { target: { value: ' sk-ant-test ' } })
|
||||
const openaiInput = screen.getByTestId('settings-key-openai')
|
||||
fireEvent.change(openaiInput, { target: { value: ' sk-openai-test ' } })
|
||||
|
||||
fireEvent.click(screen.getByTestId('settings-save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
anthropic_key: 'sk-ant-test',
|
||||
openai_key: null,
|
||||
anthropic_key: null,
|
||||
openai_key: 'sk-openai-test',
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
github_username: null,
|
||||
@@ -99,17 +110,17 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('converts empty/whitespace keys to null', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
// Clear the anthropic key field
|
||||
const anthropicInput = screen.getByTestId('settings-key-anthropic')
|
||||
fireEvent.change(anthropicInput, { target: { value: ' ' } })
|
||||
// Clear the openai key field
|
||||
const openaiInput = screen.getByTestId('settings-key-openai')
|
||||
fireEvent.change(openaiInput, { target: { value: ' ' } })
|
||||
|
||||
fireEvent.click(screen.getByTestId('settings-save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
anthropic_key: null,
|
||||
openai_key: 'sk-openai-test456',
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
github_username: null,
|
||||
@@ -119,7 +130,7 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
@@ -127,7 +138,7 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Close settings'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
@@ -135,7 +146,7 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('calls onClose on Escape key', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
@@ -143,15 +154,15 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('saves on Cmd+Enter', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
const anthropicInput = screen.getByTestId('settings-key-anthropic')
|
||||
fireEvent.change(anthropicInput, { target: { value: 'sk-ant-test' } })
|
||||
const openaiInput = screen.getByTestId('settings-key-openai')
|
||||
fireEvent.change(openaiInput, { target: { value: 'sk-openai-test' } })
|
||||
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Enter', metaKey: true })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
anthropic_key: 'sk-ant-test',
|
||||
openai_key: null,
|
||||
anthropic_key: null,
|
||||
openai_key: 'sk-openai-test',
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
github_username: null,
|
||||
@@ -161,7 +172,7 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('calls onClose when clicking backdrop', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('settings-panel'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
@@ -169,46 +180,46 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('clears a key field when X button is clicked', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
const clearBtn = screen.getByTestId('clear-anthropic')
|
||||
const clearBtn = screen.getByTestId('clear-openai')
|
||||
fireEvent.click(clearBtn)
|
||||
|
||||
const anthropicInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement
|
||||
expect(anthropicInput.value).toBe('')
|
||||
const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
|
||||
expect(openaiInput.value).toBe('')
|
||||
})
|
||||
|
||||
it('shows keyboard shortcut hint in footer', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
expect(screen.getByText(/to open settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resets fields when reopened with different settings', () => {
|
||||
const { rerender } = render(
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
// Verify initial state
|
||||
const anthropicInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement
|
||||
expect(anthropicInput.value).toBe('sk-ant-api03-test123')
|
||||
const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
|
||||
expect(openaiInput.value).toBe('sk-openai-test456')
|
||||
|
||||
// Close and reopen with different settings
|
||||
rerender(
|
||||
<SettingsPanel open={false} settings={populatedSettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={false} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
const newSettings: Settings = { ...emptySettings, anthropic_key: 'new-key' }
|
||||
const newSettings: Settings = { ...emptySettings, openai_key: 'new-key' }
|
||||
rerender(
|
||||
<SettingsPanel open={true} settings={newSettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={newSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
const updatedInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement
|
||||
const updatedInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
|
||||
expect(updatedInput.value).toBe('new-key')
|
||||
})
|
||||
|
||||
describe('GitHub OAuth section', () => {
|
||||
it('shows Login with GitHub button when not connected', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
expect(screen.getByTestId('github-login')).toBeInTheDocument()
|
||||
expect(screen.getByText('Login with GitHub')).toBeInTheDocument()
|
||||
@@ -216,7 +227,7 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('does not show GitHub token input field', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
expect(screen.queryByTestId('settings-key-github-token')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('ghp_... or gho_...')).not.toBeInTheDocument()
|
||||
@@ -229,7 +240,7 @@ describe('SettingsPanel', () => {
|
||||
github_username: 'lucaong',
|
||||
}
|
||||
render(
|
||||
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
expect(screen.getByTestId('github-connected')).toBeInTheDocument()
|
||||
expect(screen.getByText('lucaong')).toBeInTheDocument()
|
||||
@@ -244,7 +255,7 @@ describe('SettingsPanel', () => {
|
||||
github_username: 'lucaong',
|
||||
}
|
||||
render(
|
||||
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('github-disconnect'))
|
||||
|
||||
@@ -272,11 +283,8 @@ describe('SettingsPanel', () => {
|
||||
return null
|
||||
})
|
||||
|
||||
// Mock window.open
|
||||
const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
@@ -286,13 +294,62 @@ describe('SettingsPanel', () => {
|
||||
expect(screen.getByTestId('github-user-code')).toHaveTextContent('TEST-1234')
|
||||
})
|
||||
|
||||
expect(windowOpen).toHaveBeenCalledWith('https://github.com/login/device', '_blank')
|
||||
windowOpen.mockRestore()
|
||||
expect(mockOpenExternalUrl).toHaveBeenCalledWith('https://github.com/login/device')
|
||||
})
|
||||
|
||||
it('shows verification URL as clickable link in waiting state', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_device_flow_start') {
|
||||
return {
|
||||
device_code: 'test_device_code',
|
||||
user_code: 'TEST-1234',
|
||||
verification_uri: 'https://github.com/login/device',
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}
|
||||
}
|
||||
if (cmd === 'github_device_flow_poll') {
|
||||
return { status: 'pending', access_token: null, error: 'authorization_pending' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
|
||||
await waitFor(() => {
|
||||
const urlButton = screen.getByTestId('github-open-url')
|
||||
expect(urlButton).toBeInTheDocument()
|
||||
expect(urlButton).toHaveTextContent('https://github.com/login/device')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows retry button when OAuth flow errors', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'github_device_flow_start') {
|
||||
throw 'Network error'
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('github-error')).toHaveTextContent('Network error')
|
||||
expect(screen.getByTestId('github-retry')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows GitHub section description about connecting', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
expect(screen.getByText(/Connect your GitHub account/)).toBeInTheDocument()
|
||||
})
|
||||
@@ -307,7 +364,7 @@ describe('SettingsPanel', () => {
|
||||
})
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('github-login'))
|
||||
@@ -329,7 +386,7 @@ describe('SettingsPanel', () => {
|
||||
})
|
||||
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
|
||||
)
|
||||
|
||||
const loginBtn = screen.getByTestId('github-login') as HTMLButtonElement
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { X, Eye, EyeSlash, GithubLogo, SignOut, CircleNotch } from '@phosphor-icons/react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from '../types'
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
import { X, Eye, EyeSlash, GithubLogo, SignOut, Check, Plus } from '@phosphor-icons/react'
|
||||
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
|
||||
import type { Settings, ThemeFile } from '../types'
|
||||
import type { ThemeManager } from '../hooks/useThemeManager'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
open: boolean
|
||||
settings: Settings
|
||||
onSave: (settings: Settings) => void
|
||||
onClose: () => void
|
||||
themeManager: ThemeManager
|
||||
}
|
||||
|
||||
|
||||
@@ -73,8 +70,6 @@ function KeyField({ label, placeholder, value, onChange, onClear }: KeyFieldProp
|
||||
|
||||
// --- GitHub OAuth Section ---
|
||||
|
||||
type OAuthStatus = 'idle' | 'waiting' | 'error'
|
||||
|
||||
interface GitHubSectionProps {
|
||||
githubUsername: string | null
|
||||
githubToken: string | null
|
||||
@@ -82,120 +77,14 @@ interface GitHubSectionProps {
|
||||
onDisconnect: () => void
|
||||
}
|
||||
|
||||
/** Process a device flow poll result. Returns 'done' if auth complete, 'continue' to keep polling. */
|
||||
function processPollResult(
|
||||
result: DeviceFlowPollResult,
|
||||
callbacks: {
|
||||
onComplete: (token: string) => Promise<void>
|
||||
onExpired: () => void
|
||||
onError: (msg: string) => void
|
||||
},
|
||||
): 'done' | 'continue' {
|
||||
if (result.status === 'complete' && result.access_token) {
|
||||
callbacks.onComplete(result.access_token)
|
||||
return 'done'
|
||||
}
|
||||
if (result.status === 'expired') {
|
||||
callbacks.onExpired()
|
||||
return 'done'
|
||||
}
|
||||
if (result.status === 'error') {
|
||||
callbacks.onError(result.error ?? 'Authorization failed.')
|
||||
return 'done'
|
||||
}
|
||||
return 'continue' // status === 'pending'
|
||||
}
|
||||
|
||||
function GitHubSection({ githubUsername, githubToken, onConnected, onDisconnect }: GitHubSectionProps) {
|
||||
const [oauthStatus, setOauthStatus] = useState<OAuthStatus>('idle')
|
||||
const [userCode, setUserCode] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const pollingRef = useRef(false)
|
||||
const deviceCodeRef = useRef<string | null>(null)
|
||||
|
||||
const isConnected = !!githubToken && !!githubUsername
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
pollingRef.current = false
|
||||
deviceCodeRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => { pollingRef.current = false }
|
||||
}, [])
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
setOauthStatus('waiting')
|
||||
setErrorMessage(null)
|
||||
setUserCode(null)
|
||||
|
||||
try {
|
||||
const flowStart = await tauriCall<DeviceFlowStart>('github_device_flow_start')
|
||||
setUserCode(flowStart.user_code)
|
||||
deviceCodeRef.current = flowStart.device_code
|
||||
window.open(flowStart.verification_uri, '_blank')
|
||||
|
||||
pollingRef.current = true
|
||||
const intervalMs = Math.max(flowStart.interval * 1000, 5000)
|
||||
|
||||
const pollLoop = async () => {
|
||||
while (pollingRef.current && deviceCodeRef.current) {
|
||||
await new Promise(r => setTimeout(r, intervalMs))
|
||||
if (!pollingRef.current) break
|
||||
|
||||
const result = await tauriCall<DeviceFlowPollResult>('github_device_flow_poll', {
|
||||
deviceCode: deviceCodeRef.current,
|
||||
})
|
||||
const outcome = processPollResult(result, {
|
||||
onComplete: async (token) => {
|
||||
const user = await tauriCall<GitHubUser>('github_get_user', { token })
|
||||
stopPolling()
|
||||
setOauthStatus('idle')
|
||||
setUserCode(null)
|
||||
onConnected(token, user.login)
|
||||
},
|
||||
onExpired: () => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage('Authorization expired. Please try again.')
|
||||
},
|
||||
onError: (msg) => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(msg)
|
||||
},
|
||||
})
|
||||
if (outcome === 'done') return
|
||||
}
|
||||
}
|
||||
|
||||
pollLoop().catch(err => {
|
||||
stopPolling()
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Polling failed.')
|
||||
})
|
||||
} catch (err) {
|
||||
setOauthStatus('error')
|
||||
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Failed to start login.')
|
||||
}
|
||||
}, [onConnected, stopPolling])
|
||||
|
||||
const resetOAuth = useCallback(() => {
|
||||
stopPolling()
|
||||
setOauthStatus('idle')
|
||||
setUserCode(null)
|
||||
setErrorMessage(null)
|
||||
}, [stopPolling])
|
||||
|
||||
if (isConnected && oauthStatus === 'idle') {
|
||||
return <GitHubConnectedRow username={githubUsername!} onDisconnect={() => { resetOAuth(); onDisconnect() }} />
|
||||
if (isConnected) {
|
||||
return <GitHubConnectedRow username={githubUsername!} onDisconnect={onDisconnect} />
|
||||
}
|
||||
|
||||
if (oauthStatus === 'waiting' && userCode) {
|
||||
return <GitHubWaitingView userCode={userCode} onCancel={resetOAuth} />
|
||||
}
|
||||
|
||||
return <GitHubLoginButton onLogin={handleLogin} disabled={oauthStatus === 'waiting'} errorMessage={errorMessage} />
|
||||
return <GitHubDeviceFlow onConnected={onConnected} />
|
||||
}
|
||||
|
||||
function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDisconnect: () => void }) {
|
||||
@@ -224,82 +113,38 @@ function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDi
|
||||
)
|
||||
}
|
||||
|
||||
function GitHubWaitingView({ userCode, onCancel }: { userCode: string; onCancel: () => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }} data-testid="github-waiting">
|
||||
<div
|
||||
className="border border-border rounded px-4 py-3"
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 8, textAlign: 'center' }}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>Enter this code on GitHub:</div>
|
||||
<div
|
||||
style={{ fontSize: 24, fontWeight: 700, letterSpacing: 4, color: 'var(--foreground)', fontFamily: 'monospace' }}
|
||||
data-testid="github-user-code"
|
||||
>
|
||||
{userCode}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2" style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>
|
||||
<CircleNotch size={14} className="animate-spin" />
|
||||
Waiting for authorization...
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground"
|
||||
style={{ fontSize: 12, padding: '6px 12px', alignSelf: 'center' }}
|
||||
onClick={onCancel}
|
||||
data-testid="github-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GitHubLoginButton({ onLogin, disabled, errorMessage }: { onLogin: () => void; disabled: boolean; errorMessage: string | null }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button
|
||||
className="border-none rounded cursor-pointer flex items-center justify-center gap-2"
|
||||
style={{ fontSize: 13, fontWeight: 500, padding: '8px 16px', background: 'var(--foreground)', color: 'var(--background)', height: 36 }}
|
||||
onClick={onLogin}
|
||||
disabled={disabled}
|
||||
data-testid="github-login"
|
||||
>
|
||||
<GithubLogo size={16} weight="fill" />
|
||||
Login with GitHub
|
||||
</button>
|
||||
{errorMessage && (
|
||||
<div style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)' }} data-testid="github-error">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Settings Panel ---
|
||||
|
||||
export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanelProps) {
|
||||
export function SettingsPanel({ open, settings, onSave, onClose, themeManager }: SettingsPanelProps) {
|
||||
if (!open) return null
|
||||
return <SettingsPanelInner settings={settings} onSave={onSave} onClose={onClose} />
|
||||
return <SettingsPanelInner settings={settings} onSave={onSave} onClose={onClose} themeManager={themeManager} />
|
||||
}
|
||||
|
||||
function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelProps, 'open'>) {
|
||||
const [anthropicKey, setAnthropicKey] = useState(settings.anthropic_key ?? '')
|
||||
function SettingsPanelInner({ settings, onSave, onClose, themeManager }: Omit<SettingsPanelProps, 'open'>) {
|
||||
const [openaiKey, setOpenaiKey] = useState(settings.openai_key ?? '')
|
||||
const [googleKey, setGoogleKey] = useState(settings.google_key ?? '')
|
||||
const [githubToken, setGithubToken] = useState(settings.github_token)
|
||||
const [githubUsername, setGithubUsername] = useState(settings.github_username)
|
||||
const [pullInterval, setPullInterval] = useState(settings.auto_pull_interval_minutes ?? 5)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-focus first input when settings panel opens
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const input = panelRef.current?.querySelector('input')
|
||||
input?.focus()
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
const buildSettings = useCallback((ghOverride?: { token: string | null; username: string | null }): Settings => ({
|
||||
anthropic_key: anthropicKey.trim() || null,
|
||||
anthropic_key: null,
|
||||
openai_key: openaiKey.trim() || null,
|
||||
google_key: googleKey.trim() || null,
|
||||
github_token: ghOverride ? ghOverride.token : (githubToken ?? null),
|
||||
github_username: ghOverride ? ghOverride.username : (githubUsername ?? null),
|
||||
auto_pull_interval_minutes: pullInterval,
|
||||
}), [anthropicKey, openaiKey, googleKey, githubToken, githubUsername, pullInterval])
|
||||
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval])
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(buildSettings())
|
||||
@@ -338,17 +183,18 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
data-testid="settings-panel"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="bg-background border border-border rounded-lg shadow-xl"
|
||||
style={{ width: 520, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<SettingsHeader onClose={onClose} />
|
||||
<SettingsBody
|
||||
anthropicKey={anthropicKey} setAnthropicKey={setAnthropicKey}
|
||||
openaiKey={openaiKey} setOpenaiKey={setOpenaiKey}
|
||||
googleKey={googleKey} setGoogleKey={setGoogleKey}
|
||||
githubToken={githubToken ?? null} githubUsername={githubUsername ?? null}
|
||||
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
|
||||
pullInterval={pullInterval} setPullInterval={setPullInterval}
|
||||
themeManager={themeManager}
|
||||
/>
|
||||
<SettingsFooter onClose={onClose} onSave={handleSave} />
|
||||
</div>
|
||||
@@ -375,13 +221,13 @@ function SettingsHeader({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
|
||||
interface SettingsBodyProps {
|
||||
anthropicKey: string; setAnthropicKey: (v: string) => void
|
||||
openaiKey: string; setOpenaiKey: (v: string) => void
|
||||
googleKey: string; setGoogleKey: (v: string) => void
|
||||
githubToken: string | null; githubUsername: string | null
|
||||
onGitHubConnected: (token: string, username: string) => void
|
||||
onGitHubDisconnect: () => void
|
||||
pullInterval: number; setPullInterval: (v: number) => void
|
||||
themeManager: ThemeManager
|
||||
}
|
||||
|
||||
function SettingsBody(props: SettingsBodyProps) {
|
||||
@@ -394,7 +240,6 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KeyField label="Anthropic" placeholder="sk-ant-..." value={props.anthropicKey} onChange={props.setAnthropicKey} onClear={() => props.setAnthropicKey('')} />
|
||||
<KeyField label="OpenAI" placeholder="sk-..." value={props.openaiKey} onChange={props.setOpenaiKey} onClear={() => props.setOpenaiKey('')} />
|
||||
<KeyField label="Google AI" placeholder="AIza..." value={props.googleKey} onChange={props.setGoogleKey} onClear={() => props.setGoogleKey('')} />
|
||||
|
||||
@@ -440,10 +285,81 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
<option value={30}>30</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
<AppearanceSection themeManager={props.themeManager} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Appearance Section ---
|
||||
|
||||
function ColorSwatch({ color }: { color: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{ width: 14, height: 14, borderRadius: 3, background: color, border: '1px solid var(--border)', flexShrink: 0 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeCard({ theme, active, onSelect }: { theme: ThemeFile; active: boolean; onSelect: () => void }) {
|
||||
const swatchColors = ['background', 'foreground', 'primary', 'border', 'muted']
|
||||
return (
|
||||
<button
|
||||
className="border rounded cursor-pointer text-left"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', width: '100%',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
borderColor: active ? 'var(--primary)' : 'var(--border)',
|
||||
}}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
data-testid={`theme-card-${theme.id}`}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 3 }}>
|
||||
{swatchColors.map(key => theme.colors[key] && <ColorSwatch key={key} color={theme.colors[key]} />)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--foreground)' }}>{theme.name}</div>
|
||||
{theme.description && (
|
||||
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{theme.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{active && <Check size={14} weight="bold" style={{ color: 'var(--primary)', flexShrink: 0 }} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function AppearanceSection({ themeManager }: { themeManager: ThemeManager }) {
|
||||
const { themes, activeThemeId, switchTheme, createTheme } = themeManager
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Appearance</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
Choose a theme for your vault. Themes are stored in <code>_themes/</code> and synced with Git.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }} data-testid="theme-list">
|
||||
{themes.map(theme => (
|
||||
<ThemeCard key={theme.id} theme={theme} active={theme.id === activeThemeId} onSelect={() => switchTheme(theme.id)} />
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground hover:border-foreground"
|
||||
style={{ fontSize: 12, padding: '6px 12px', display: 'flex', alignItems: 'center', gap: 4, alignSelf: 'flex-start' }}
|
||||
onClick={() => createTheme(activeThemeId ?? undefined)}
|
||||
type="button"
|
||||
data-testid="create-theme"
|
||||
>
|
||||
<Plus size={14} />
|
||||
New Theme
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsFooter({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -39,6 +39,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -64,6 +66,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -89,6 +93,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -114,6 +120,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -139,6 +147,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -164,6 +174,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -189,6 +201,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -214,6 +228,8 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
@@ -227,7 +243,7 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders section group headers with new labels', () => {
|
||||
it('renders section group headers only for types present in entries', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Projects')).toBeInTheDocument()
|
||||
expect(screen.getByText('Experiments')).toBeInTheDocument()
|
||||
@@ -236,7 +252,8 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('People')).toBeInTheDocument()
|
||||
expect(screen.getByText('Events')).toBeInTheDocument()
|
||||
expect(screen.getByText('Topics')).toBeInTheDocument()
|
||||
expect(screen.getByText('Types')).toBeInTheDocument()
|
||||
// No entries with isA: 'Type' in mockEntries → Types section absent
|
||||
expect(screen.queryByText('Types')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows entity names under their section groups after expanding', () => {
|
||||
@@ -295,6 +312,39 @@ describe('Sidebar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('expands a collapsed section when clicking its header', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Sections start collapsed — items hidden
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
// Click the section header text (not the chevron)
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
// Section should now be expanded
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses an expanded+selected section when clicking its header again', () => {
|
||||
const projectSelection: SidebarSelection = { kind: 'sectionGroup', type: 'Project' }
|
||||
render(<Sidebar entries={mockEntries} selection={projectSelection} onSelect={() => {}} />)
|
||||
// First click expands (starts collapsed) and selects
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
// Second click: section is expanded + selected → should collapse
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects but keeps expanded an unselected expanded section when clicking its header', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
// Expand via chevron first
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
// Click the header — section is expanded but not selected → should select and stay expanded
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'sectionGroup', type: 'Project' })
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with sectionGroup for People', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
@@ -328,7 +378,7 @@ describe('Sidebar', () => {
|
||||
const onCreateType = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
|
||||
const createButtons = screen.getAllByTitle(/^New /)
|
||||
expect(createButtons.length).toBe(8) // Projects, Experiments, Responsibilities, Procedures, People, Events, Topics, Types
|
||||
expect(createButtons.length).toBe(7) // Projects, Experiments, Responsibilities, Procedures, People, Events, Topics (no Type entries → no Types section)
|
||||
})
|
||||
|
||||
it('calls onCreateType with correct type when + button is clicked', () => {
|
||||
@@ -398,6 +448,8 @@ describe('Sidebar', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -423,6 +475,8 @@ describe('Sidebar', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -445,14 +499,41 @@ describe('Sidebar', () => {
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
path: '/vault/book/ddia.md',
|
||||
filename: 'ddia.md',
|
||||
title: 'Designing Data-Intensive Applications',
|
||||
isA: 'Book',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 400,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
|
||||
it('renders custom type sections derived from Type entries', () => {
|
||||
it('renders custom type sections derived from actual entries', () => {
|
||||
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateType={() => {}} />)
|
||||
expect(screen.getByText('Books')).toBeInTheDocument()
|
||||
expect(screen.getByText('Recipes')).toBeInTheDocument()
|
||||
@@ -478,6 +559,36 @@ describe('Sidebar', () => {
|
||||
expect(onCreateNewType).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not show section for type with zero active entries', () => {
|
||||
// Only Type definitions exist for Book, no actual Book instances
|
||||
const entriesNoBookInstance = entriesWithCustomTypes.filter((e) => !(e.isA === 'Book' && e.title !== 'Book'))
|
||||
render(<Sidebar entries={entriesNoBookInstance} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('Books')).not.toBeInTheDocument()
|
||||
// Recipes still has an instance (Pasta Carbonara)
|
||||
expect(screen.getByText('Recipes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides type section when all entries of that type are trashed', () => {
|
||||
const entriesWithTrashedOnly: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/event/cancelled.md', filename: 'cancelled.md', title: 'Cancelled Event',
|
||||
isA: 'Event', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: true, trashedAt: 1700000000,
|
||||
modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={entriesWithTrashedOnly} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('Events')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows no sections when entries list is empty', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('Projects')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('People')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Events')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show built-in types as custom sections', () => {
|
||||
const projectTypeEntry: VaultEntry = {
|
||||
path: '/vault/type/project.md',
|
||||
@@ -502,6 +613,8 @@ describe('Sidebar', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
render(<Sidebar entries={[...mockEntries, projectTypeEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
@@ -509,6 +622,52 @@ describe('Sidebar', () => {
|
||||
const projectLabels = screen.getAllByText('Projects')
|
||||
expect(projectLabels.length).toBe(1)
|
||||
})
|
||||
|
||||
it('uses sidebarLabel from Type entry instead of auto-pluralization', () => {
|
||||
const entriesWithLabel: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
{
|
||||
path: '/vault/type/news.md', filename: 'news.md', title: 'News', 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: 'News', outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
path: '/vault/news/breaking.md', filename: 'breaking.md', title: 'Breaking Story', isA: 'News',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 300, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={entriesWithLabel} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Should show "News" (custom label), not "Newses" (auto-pluralized)
|
||||
expect(screen.getByText('News')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Newses')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses sidebarLabel to override built-in type label', () => {
|
||||
const entriesWithBuiltInOverride: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
{
|
||||
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', 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: 'Contacts', outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={entriesWithBuiltInOverride} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Contacts')).toBeInTheDocument()
|
||||
expect(screen.queryByText('People')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to auto-pluralization when sidebarLabel is null', () => {
|
||||
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Recipe has no sidebarLabel → should auto-pluralize to "Recipes"
|
||||
expect(screen.getByText('Recipes')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('customize section visibility', () => {
|
||||
@@ -616,21 +775,21 @@ describe('Sidebar', () => {
|
||||
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: 5, outgoingLinks: [],
|
||||
relationships: {}, icon: null, color: null, order: 5, sidebarLabel: null, outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', 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: 0, outgoingLinks: [],
|
||||
relationships: {}, icon: null, color: null, order: 0, sidebarLabel: null, outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', 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: 1, outgoingLinks: [],
|
||||
relationships: {}, icon: null, color: null, order: 1, sidebarLabel: null, outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
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 {
|
||||
@@ -31,6 +32,7 @@ interface SidebarProps {
|
||||
onCreateType?: (type: string) => void
|
||||
onCreateNewType?: () => void
|
||||
onCustomizeType?: (typeName: string, icon: string, color: string) => void
|
||||
onUpdateTypeTemplate?: (typeName: string, template: string) => void
|
||||
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
|
||||
modifiedCount?: number
|
||||
onCommitPush?: () => void
|
||||
@@ -48,7 +50,8 @@ const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
|
||||
{ label: 'Types', type: 'Type', Icon: StackSimple },
|
||||
]
|
||||
|
||||
const BUILT_IN_TYPES = new Set(BUILT_IN_SECTION_GROUPS.map((s) => s.type))
|
||||
/** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */
|
||||
const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg]))
|
||||
|
||||
// --- Hooks ---
|
||||
|
||||
@@ -63,19 +66,32 @@ function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boole
|
||||
}, [ref, isOpen, onClose])
|
||||
}
|
||||
|
||||
function applyOverrides(typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
return BUILT_IN_SECTION_GROUPS.map((sg) => {
|
||||
const te = typeEntryMap[sg.type]
|
||||
if (!te?.icon && !te?.color) return sg
|
||||
return { ...sg, Icon: te?.icon ? resolveIcon(te.icon) : sg.Icon, customColor: te?.color ?? null }
|
||||
})
|
||||
/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */
|
||||
function collectActiveTypes(entries: VaultEntry[]): Set<string> {
|
||||
const types = new Set<string>()
|
||||
for (const e of entries) {
|
||||
if (e.isA && e.isA !== 'Note' && !e.trashed && !e.archived) types.add(e.isA)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
function buildCustomSections(entries: VaultEntry[]): SectionGroup[] {
|
||||
return entries
|
||||
.filter((e) => e.isA === 'Type' && !BUILT_IN_TYPES.has(e.title))
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
.map((e) => ({ label: e.title + 's', type: e.title, Icon: resolveIcon(e.icon), customColor: e.color }))
|
||||
/** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */
|
||||
function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry>): SectionGroup {
|
||||
const builtIn = BUILT_IN_TYPE_MAP.get(type)
|
||||
const typeEntry = typeEntryMap[type]
|
||||
const customColor = typeEntry?.color ?? null
|
||||
const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type))
|
||||
if (builtIn) {
|
||||
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
|
||||
return { ...builtIn, label, Icon, customColor }
|
||||
}
|
||||
return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
|
||||
}
|
||||
|
||||
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */
|
||||
function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
const activeTypes = collectActiveTypes(entries)
|
||||
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
|
||||
}
|
||||
|
||||
function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
@@ -89,9 +105,8 @@ function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, Vault
|
||||
function useSidebarSections(entries: VaultEntry[], isSectionVisible: (type: string) => boolean) {
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const allSectionGroups = useMemo(() => {
|
||||
const built = applyOverrides(typeEntryMap)
|
||||
const custom = buildCustomSections(entries)
|
||||
return sortSections([...built, ...custom], typeEntryMap)
|
||||
const sections = buildDynamicSections(entries, typeEntryMap)
|
||||
return sortSections(sections, typeEntryMap)
|
||||
}, [entries, typeEntryMap])
|
||||
const visibleSections = useMemo(() => allSectionGroups.filter((g) => isSectionVisible(g.type)), [allSectionGroups, isSectionVisible])
|
||||
const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections])
|
||||
@@ -215,10 +230,11 @@ function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: {
|
||||
)
|
||||
}
|
||||
|
||||
function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose }: {
|
||||
function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChangeTemplate, onClose }: {
|
||||
target: string | null; typeEntryMap: Record<string, VaultEntry>
|
||||
innerRef: React.Ref<HTMLDivElement>
|
||||
onCustomize: (prop: 'icon' | 'color', value: string) => void
|
||||
onChangeTemplate: (template: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
if (!target) return null
|
||||
@@ -227,8 +243,10 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose
|
||||
<TypeCustomizePopover
|
||||
currentIcon={typeEntryMap[target]?.icon ?? null}
|
||||
currentColor={typeEntryMap[target]?.color ?? null}
|
||||
currentTemplate={typeEntryMap[target]?.template ?? null}
|
||||
onChangeIcon={(icon) => onCustomize('icon', icon)}
|
||||
onChangeColor={(color) => onCustomize('color', color)}
|
||||
onChangeTemplate={onChangeTemplate}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
@@ -239,7 +257,7 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onCustomizeType, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
@@ -288,6 +306,10 @@ export const Sidebar = memo(function Sidebar({
|
||||
applyCustomization(customizeTarget, typeEntryMap, onCustomizeType, prop, value)
|
||||
}, [customizeTarget, typeEntryMap, onCustomizeType])
|
||||
|
||||
const handleChangeTemplate = useCallback((template: string) => {
|
||||
if (customizeTarget) onUpdateTypeTemplate?.(customizeTarget, template)
|
||||
}, [customizeTarget, onUpdateTypeTemplate])
|
||||
|
||||
const sectionProps = {
|
||||
entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onContextMenu: handleContextMenu, onToggle: toggleSection,
|
||||
@@ -332,7 +354,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
|
||||
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} />
|
||||
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onClose={closeCustomizeTarget} />
|
||||
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />
|
||||
</aside>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -156,7 +156,11 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
|
||||
<div
|
||||
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
|
||||
style={{ padding: '6px 8px 6px 6px', borderRadius: 4, gap: 4 }}
|
||||
onClick={onSelect} onContextMenu={onContextMenu}
|
||||
onClick={() => {
|
||||
if (isCollapsed) { onToggle(); onSelect() }
|
||||
else if (isActive) { onToggle() }
|
||||
else { onSelect() }
|
||||
}} onContextMenu={onContextMenu}
|
||||
>
|
||||
<div className="flex items-center" style={{ gap: 4 }}>
|
||||
<div className="flex shrink-0 items-center justify-center text-muted-foreground opacity-0 group-hover/section:opacity-50 hover:!opacity-100 cursor-grab" style={{ width: 16, height: 16 }} {...dragHandleProps} aria-label={`Drag to reorder ${label}`}>
|
||||
|
||||
@@ -11,18 +11,31 @@ import { WikilinkSuggestionMenu, type WikilinkSuggestionItem } from './WikilinkS
|
||||
import type { VaultEntry } from '../types'
|
||||
import { _wikilinkEntriesRef } from './editorSchema'
|
||||
|
||||
/** Insert an image block after the current cursor position. */
|
||||
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
const editorRef = useRef(editor)
|
||||
useEffect(() => { editorRef.current = editor }, [editor])
|
||||
return useCallback((url: string) => {
|
||||
const e = editorRef.current
|
||||
const cursorBlock = e.getTextCursorPosition().block
|
||||
e.insertBlocks([{ type: 'image' as const, props: { url } }], cursorBlock, 'after')
|
||||
}, [])
|
||||
}
|
||||
|
||||
/** Single BlockNote editor view — content is swapped via replaceBlocks */
|
||||
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: {
|
||||
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath }: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
entries: VaultEntry[]
|
||||
onNavigateWikilink: (target: string) => void
|
||||
onChange?: () => void
|
||||
vaultPath?: string
|
||||
}) {
|
||||
const navigateRef = useRef(onNavigateWikilink)
|
||||
useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink])
|
||||
const { cssVars } = useEditorTheme()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { isDragOver } = useImageDrop({ containerRef })
|
||||
const onImageUrl = useInsertImageCallback(editor)
|
||||
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
|
||||
|
||||
useEffect(() => {
|
||||
_wikilinkEntriesRef.current = entries
|
||||
|
||||
@@ -25,9 +25,14 @@ describe('StatusBar', () => {
|
||||
expect(screen.getByText('9,200 notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays version info', () => {
|
||||
it('displays build number when provided', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} buildNumber="b223" />)
|
||||
expect(screen.getByText('b223')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays fallback build number when not provided', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
expect(screen.getByText('v0.4.2')).toBeInTheDocument()
|
||||
expect(screen.getByText('b?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not display branch name', () => {
|
||||
|
||||
@@ -24,6 +24,9 @@ interface StatusBarProps {
|
||||
conflictCount?: number
|
||||
lastCommitInfo?: LastCommitInfo | null
|
||||
onTriggerSync?: () => void
|
||||
zoomLevel?: number
|
||||
onZoomReset?: () => void
|
||||
buildNumber?: string
|
||||
}
|
||||
|
||||
function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) {
|
||||
@@ -111,6 +114,7 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConn
|
||||
const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const
|
||||
const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
const SEP_STYLE = { color: 'var(--border)' } as const
|
||||
const SYNC_ICON_MAP: Record<string, typeof RefreshCw> = { syncing: Loader2, conflict: AlertTriangle }
|
||||
|
||||
function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string {
|
||||
if (status === 'syncing') return 'Syncing…'
|
||||
@@ -129,7 +133,30 @@ function syncIconColor(status: SyncStatus): string {
|
||||
return 'var(--accent-green)'
|
||||
}
|
||||
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync }: StatusBarProps) {
|
||||
function CommitBadge({ info }: { info: LastCommitInfo }) {
|
||||
if (info.commitUrl) {
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
onClick={() => openExternalUrl(info.commitUrl!)}
|
||||
style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', textDecoration: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={`Open commit ${info.shortHash} on GitHub`}
|
||||
data-testid="status-commit-link"
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--foreground)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--muted-foreground)' }}
|
||||
>
|
||||
<GitCommitHorizontal size={13} />{info.shortHash}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span style={ICON_STYLE} data-testid="status-commit-hash">
|
||||
<GitCommitHorizontal size={13} />{info.shortHash}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset, buildNumber }: StatusBarProps) {
|
||||
// Force re-render every 30s to keep relative time label fresh
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
@@ -138,14 +165,14 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
}, [])
|
||||
|
||||
const syncLabel = formatSyncLabel(syncStatus, lastSyncTime)
|
||||
const SyncIcon = syncStatus === 'syncing' ? Loader2 : syncStatus === 'conflict' ? AlertTriangle : RefreshCw
|
||||
const SyncIcon = SYNC_ICON_MAP[syncStatus] ?? RefreshCw
|
||||
|
||||
return (
|
||||
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} />
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={ICON_STYLE}><Package size={13} />v0.4.2</span>
|
||||
<span style={ICON_STYLE} data-testid="status-build-number"><Package size={13} />{buildNumber ?? 'b?'}</span>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
@@ -156,25 +183,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(syncStatus) }} className={syncStatus === 'syncing' ? 'animate-spin' : ''} />{syncLabel}
|
||||
</span>
|
||||
{lastCommitInfo && (
|
||||
lastCommitInfo.commitUrl ? (
|
||||
<span
|
||||
role="button"
|
||||
onClick={() => openExternalUrl(lastCommitInfo.commitUrl!)}
|
||||
style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', textDecoration: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={`Open commit ${lastCommitInfo.shortHash} on GitHub`}
|
||||
data-testid="status-commit-link"
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--foreground)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--muted-foreground)' }}
|
||||
>
|
||||
<GitCommitHorizontal size={13} />{lastCommitInfo.shortHash}
|
||||
</span>
|
||||
) : (
|
||||
<span style={ICON_STYLE} data-testid="status-commit-hash">
|
||||
<GitCommitHorizontal size={13} />{lastCommitInfo.shortHash}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
|
||||
{conflictCount > 0 && (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
@@ -201,6 +210,17 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={ICON_STYLE}><Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />Claude Sonnet 4</span>
|
||||
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>
|
||||
{zoomLevel !== 100 && (
|
||||
<span
|
||||
role="button"
|
||||
onClick={onZoomReset}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="Reset zoom (⌘0)"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-zoom"
|
||||
>{zoomLevel}%</span>
|
||||
)}
|
||||
<span style={DISABLED_STYLE} title="Coming soon"><Bell size={14} /></span>
|
||||
<span
|
||||
role="button"
|
||||
|
||||