Compare commits
60 Commits
v0.1.0
...
v0.2026022
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
39
.claude-done
@@ -1,36 +1,3 @@
|
||||
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
|
||||
Task: pencil-ui-design-light-mode
|
||||
Summary: Verified all 115 top-level frames in ui-design.pen have theme:{Mode:Light}. No remaining before-variant frames found (only legitimate state-transition labels). No duplicate frames detected. Visual screenshots confirm correct light-mode rendering. Previous commit 165cc0e already applied theme to 25 dark-mode frames and removed 2 before variants.
|
||||
Commits: 1 (existing)
|
||||
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,14 @@ 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
|
||||
--fail-under-lines 85 \
|
||||
-- --test-threads=1
|
||||
echo " ✅ Rust coverage OK"
|
||||
else
|
||||
echo "⏭️ [3/4] Rust coverage — skipped (no src-tauri/ changes)"
|
||||
|
||||
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": {}
|
||||
}
|
||||
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": {}
|
||||
}
|
||||
@@ -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
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
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": {}
|
||||
}
|
||||
|
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 |
@@ -7,6 +7,7 @@ pub mod search;
|
||||
pub mod settings;
|
||||
pub mod vault;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
|
||||
use ai_chat::{AiChatRequest, AiChatResponse};
|
||||
@@ -17,18 +18,37 @@ use search::SearchResponse;
|
||||
use settings::Settings;
|
||||
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 +58,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 +94,32 @@ 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_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)
|
||||
}
|
||||
|
||||
@@ -97,6 +130,7 @@ async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
@@ -106,24 +140,49 @@ fn rename_note(
|
||||
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 +193,9 @@ 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 +233,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,6 +259,7 @@ 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
|
||||
@@ -232,7 +294,41 @@ fn run_startup_tasks() {
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -296,7 +392,10 @@ 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
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -14,6 +14,9 @@ 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 CUSTOM_IDS: &[&str] = &[
|
||||
APP_SETTINGS,
|
||||
@@ -26,6 +29,9 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VIEW_ALL,
|
||||
VIEW_TOGGLE_INSPECTOR,
|
||||
VIEW_COMMAND_PALETTE,
|
||||
VIEW_ZOOM_IN,
|
||||
VIEW_ZOOM_OUT,
|
||||
VIEW_ZOOM_RESET,
|
||||
];
|
||||
|
||||
/// IDs of menu items that should be disabled when no note tab is active.
|
||||
@@ -115,6 +121,18 @@ 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)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "View")
|
||||
.item(&editor_only)
|
||||
@@ -123,6 +141,10 @@ fn build_view_menu(app: &App) -> MenuResult {
|
||||
.separator()
|
||||
.item(&toggle_inspector)
|
||||
.separator()
|
||||
.item(&zoom_in)
|
||||
.item(&zoom_out)
|
||||
.item(&zoom_reset)
|
||||
.separator()
|
||||
.item(&command_palette)
|
||||
.build()?)
|
||||
}
|
||||
@@ -192,6 +214,9 @@ mod tests {
|
||||
"view-all",
|
||||
"view-toggle-inspector",
|
||||
"view-command-palette",
|
||||
"view-zoom-in",
|
||||
"view-zoom-out",
|
||||
"view-zoom-reset",
|
||||
];
|
||||
for id in &expected {
|
||||
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");
|
||||
|
||||
396
src-tauri/src/vault/getting_started.rs
Normal file
@@ -0,0 +1,396 @@
|
||||
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,
|
||||
}
|
||||
|
||||
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))?;
|
||||
|
||||
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))?;
|
||||
}
|
||||
|
||||
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("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();
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod cache;
|
||||
mod getting_started;
|
||||
mod image;
|
||||
mod migration;
|
||||
mod parsing;
|
||||
@@ -6,6 +7,7 @@ mod rename;
|
||||
mod trash;
|
||||
|
||||
pub use cache::scan_vault_cached;
|
||||
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
|
||||
pub use image::save_image;
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{rename_note, RenameResult};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,8 @@ vi.mock('./mock-tauri', () => ({
|
||||
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
|
||||
if (cmd === 'check_vault_exists') return true
|
||||
if (cmd === 'get_default_vault_path') return '/Users/mock/Documents/Laputa'
|
||||
return null
|
||||
}),
|
||||
addMockEntry: vi.fn(),
|
||||
@@ -120,8 +122,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 () => {
|
||||
|
||||
91
src/App.tsx
@@ -12,6 +12,7 @@ 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 { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteActions } from './hooks/useNoteActions'
|
||||
@@ -26,6 +27,8 @@ 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 { useOnboarding } from './hooks/useOnboarding'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { setApiKey } from './utils/ai-chat'
|
||||
import { extractOutgoingLinks } from './utils/wikilinks'
|
||||
@@ -76,13 +79,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 +115,17 @@ function App() {
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
|
||||
const vault = useVaultLoader(vaultSwitcher.vaultPath)
|
||||
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
|
||||
|
||||
// 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()
|
||||
|
||||
useEffect(() => { setApiKey(settings.anthropic_key ?? '') }, [settings.anthropic_key])
|
||||
|
||||
const autoSync = useAutoSync({
|
||||
vaultPath: vaultSwitcher.vaultPath,
|
||||
vaultPath: resolvedPath,
|
||||
intervalMinutes: settings.auto_pull_interval_minutes,
|
||||
onVaultUpdated: vault.reloadVault,
|
||||
onConflict: (files) => {
|
||||
@@ -127,7 +135,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) })
|
||||
|
||||
const navHistory = useNavigationHistory()
|
||||
|
||||
@@ -198,10 +210,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 +243,21 @@ 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 commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
@@ -234,12 +266,16 @@ function App() {
|
||||
modifiedCount: vault.modifiedFiles.length, selection,
|
||||
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
|
||||
onSearch: dialogs.openSearch,
|
||||
onCreateNote: notes.handleCreateNoteImmediate, onSave: handleSave,
|
||||
onCreateNote: notes.handleCreateNoteImmediate,
|
||||
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,
|
||||
@@ -251,6 +287,35 @@ function App() {
|
||||
|
||||
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} />
|
||||
@@ -266,7 +331,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,13 +362,14 @@ 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}
|
||||
@@ -311,11 +377,11 @@ function App() {
|
||||
/>
|
||||
</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} />
|
||||
<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} />
|
||||
<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} />
|
||||
@@ -325,6 +391,7 @@ function App() {
|
||||
onClose={dialogs.closeGitHubVault}
|
||||
onVaultCloned={vaultSwitcher.handleVaultCloned}
|
||||
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
|
||||
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1012,7 +1012,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 +1034,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 +1050,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,14 +1,16 @@
|
||||
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 { countWords } from '../utils/wikilinks'
|
||||
import {
|
||||
@@ -22,7 +24,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 +97,54 @@ 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 inline-flex items-center gap-0.5 rounded-full" style={{ backgroundColor: style.bg, padding: '1px 6px' }}>
|
||||
<span style={{ color: style.color, fontFamily: "'IBM Plex Mono', monospace", fontSize: 10, fontWeight: 600, letterSpacing: '1.2px', textTransform: 'uppercase' }}>{tag}</span>
|
||||
<button
|
||||
className="border-none bg-transparent p-0 leading-none opacity-0 transition-opacity group-hover/tag:opacity-100"
|
||||
style={{ color: style.color, fontSize: 10 }}
|
||||
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 +200,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 +231,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 +239,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 +263,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 +273,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 +335,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 +344,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 +357,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 +387,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 +420,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 +467,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 +482,9 @@ function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null
|
||||
)
|
||||
}
|
||||
|
||||
function TypeSelector({ isA, customColorKey, availableTypes, onUpdateProperty, onNavigate }: {
|
||||
function TypeSelector({ isA, customColorKey, availableTypes, typeColorKeys, onUpdateProperty, onNavigate }: {
|
||||
isA?: string | null; customColorKey?: string | null; availableTypes: string[]
|
||||
typeColorKeys: Record<string, string | null>
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onNavigate?: (target: string) => void
|
||||
}) {
|
||||
@@ -417,7 +496,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 +506,14 @@ 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}>
|
||||
<Circle className="size-3" style={{ color: getTypeColor(type, typeColorKeys[type]), fill: getTypeColor(type, typeColorKeys[type]) }} />
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -451,20 +533,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 +561,45 @@ 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
|
||||
}) {
|
||||
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 transition-colors hover:bg-muted" 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 +641,12 @@ function reconcileListUpdate(
|
||||
|
||||
function deriveTypeInfo(entries: VaultEntry[] | undefined, entryIsA: string | null) {
|
||||
const typeEntries = (entries ?? []).filter(e => e.isA === 'Type')
|
||||
const typeColorKeys: Record<string, string | null> = {}
|
||||
for (const e of typeEntries) { typeColorKeys[e.title] = e.color ?? 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,12 +658,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 +702,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 } = 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 +746,20 @@ function usePropertyPanelState(deps: PropertyPanelDeps) {
|
||||
|
||||
return {
|
||||
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
||||
availableTypes, customColorKey, vaultStatuses, propertyEntries,
|
||||
availableTypes, customColorKey, typeColorKeys, 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 +767,22 @@ export function DynamicPropertiesPanel({
|
||||
}) {
|
||||
const {
|
||||
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
||||
availableTypes, customColorKey, vaultStatuses, propertyEntries,
|
||||
availableTypes, customColorKey, typeColorKeys, 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} 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)
|
||||
|
||||
@@ -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,6 +53,8 @@ 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
|
||||
@@ -76,7 +79,7 @@ export const Editor = memo(function Editor({
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onRenameTab, onContentChange,
|
||||
onRenameTab, onContentChange, onTitleSync,
|
||||
canGoBack, canGoForward, onGoBack, onGoForward,
|
||||
}: EditorProps) {
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
@@ -87,14 +90,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,7 +128,7 @@ export const Editor = memo(function Editor({
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNote={onCreateNote}
|
||||
onReorderTabs={onReorderTabs}
|
||||
onRenameTab={onRenameTab}
|
||||
onRenameTab={handleRenameTabWithSync}
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
onGoBack={onGoBack}
|
||||
|
||||
@@ -55,6 +55,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>
|
||||
|
||||
@@ -485,25 +485,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 +563,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', () => {
|
||||
@@ -619,7 +602,7 @@ 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()
|
||||
|
||||
@@ -17,6 +17,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
|
||||
@@ -79,7 +80,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,7 +105,7 @@ 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)
|
||||
@@ -137,7 +138,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}
|
||||
|
||||
@@ -77,7 +77,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)
|
||||
})
|
||||
@@ -467,18 +467,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 { 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} />
|
||||
@@ -421,15 +414,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) => {
|
||||
|
||||
@@ -288,27 +288,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()
|
||||
})
|
||||
|
||||
@@ -996,40 +996,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 +1007,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 +1042,159 @@ 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,
|
||||
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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,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 +136,24 @@ 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 }: {
|
||||
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
|
||||
}) {
|
||||
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>
|
||||
)
|
||||
@@ -171,7 +165,7 @@ function ListView({ typeDocument, isTrashView, isChangesView, expiredTrashCount,
|
||||
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 +190,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 +270,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 +302,29 @@ 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 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 +339,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])
|
||||
|
||||
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">
|
||||
@@ -377,7 +391,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
{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} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,16 @@ 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: [],
|
||||
outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'],
|
||||
},
|
||||
{
|
||||
path: '/vault/event/retreat.md',
|
||||
@@ -52,16 +54,16 @@ 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: [],
|
||||
outgoingLinks: ['person/bob'],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -195,7 +197,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 +272,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 entry = entryLookup.get(result.path)
|
||||
const isA = entry?.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 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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Settings } from '../types'
|
||||
|
||||
// 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 +13,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,
|
||||
@@ -272,9 +276,6 @@ 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} />
|
||||
)
|
||||
@@ -286,8 +287,57 @@ 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} />
|
||||
)
|
||||
|
||||
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} />
|
||||
)
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
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 { useState, useRef, useCallback } from 'react'
|
||||
import { X, Eye, EyeSlash, GithubLogo, SignOut } from '@phosphor-icons/react'
|
||||
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
|
||||
import type { Settings } from '../types'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
open: boolean
|
||||
@@ -73,8 +68,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 +75,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,59 +111,6 @@ 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) {
|
||||
|
||||
@@ -24,6 +24,8 @@ interface StatusBarProps {
|
||||
conflictCount?: number
|
||||
lastCommitInfo?: LastCommitInfo | null
|
||||
onTriggerSync?: () => void
|
||||
zoomLevel?: number
|
||||
onZoomReset?: () => void
|
||||
}
|
||||
|
||||
function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) {
|
||||
@@ -111,6 +113,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 +132,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 }: StatusBarProps) {
|
||||
// Force re-render every 30s to keep relative time label fresh
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
@@ -138,7 +164,7 @@ 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)' }}>
|
||||
@@ -156,25 +182,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 +209,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"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { getStatusStyle, SUGGESTED_STATUSES } from '../utils/statusStyles'
|
||||
import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { getStatusStyle, SUGGESTED_STATUSES, setStatusColor, getStatusColorKey } from '../utils/statusStyles'
|
||||
import { ACCENT_COLORS } from '../utils/typeColors'
|
||||
|
||||
export function StatusPill({ status, className }: { status: string; className?: string }) {
|
||||
const style = getStatusStyle(status)
|
||||
@@ -25,33 +27,193 @@ export function StatusPill({ status, className }: { status: string; className?:
|
||||
)
|
||||
}
|
||||
|
||||
function ColorPickerRow({ status, onColorChange }: { status: string; onColorChange: (status: string, colorKey: string) => void }) {
|
||||
const currentKey = getStatusColorKey(status)
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-3 py-1.5" data-testid={`color-picker-${status}`}>
|
||||
{ACCENT_COLORS.map(c => (
|
||||
<button
|
||||
key={c.key}
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0 transition-transform hover:scale-125"
|
||||
style={{ backgroundColor: c.css }}
|
||||
onClick={(e) => { e.stopPropagation(); onColorChange(status, c.key) }}
|
||||
title={c.label}
|
||||
data-testid={`color-option-${c.key}`}
|
||||
>
|
||||
{currentKey === c.key && (
|
||||
<span style={{ color: 'white', fontSize: 8, lineHeight: 1 }}>{'\u2713'}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusOption({
|
||||
status,
|
||||
highlighted,
|
||||
onSelect,
|
||||
onMouseEnter,
|
||||
colorEditing,
|
||||
onToggleColor,
|
||||
onColorChange,
|
||||
}: {
|
||||
status: string
|
||||
highlighted: boolean
|
||||
onSelect: (status: string) => void
|
||||
onMouseEnter: () => void
|
||||
colorEditing: boolean
|
||||
onToggleColor: (status: string) => void
|
||||
onColorChange: (status: string, colorKey: string) => void
|
||||
}) {
|
||||
const style = getStatusStyle(status)
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center border-none bg-transparent px-2 py-1 text-left transition-colors"
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
backgroundColor: highlighted ? 'var(--muted)' : 'transparent',
|
||||
}}
|
||||
onClick={() => onSelect(status)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
data-testid={`status-option-${status}`}
|
||||
>
|
||||
<StatusPill status={status} />
|
||||
</button>
|
||||
<>
|
||||
<div
|
||||
className="flex w-full items-center gap-1 px-2 py-1 transition-colors"
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
backgroundColor: highlighted ? 'var(--muted)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center border-none bg-transparent p-0 text-left"
|
||||
onClick={() => onSelect(status)}
|
||||
data-testid={`status-option-${status}`}
|
||||
>
|
||||
<StatusPill status={status} />
|
||||
</button>
|
||||
<button
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0"
|
||||
style={{ backgroundColor: style.color }}
|
||||
onClick={() => onToggleColor(status)}
|
||||
title="Change color"
|
||||
data-testid={`status-color-swatch-${status}`}
|
||||
/>
|
||||
</div>
|
||||
{colorEditing && <ColorPickerRow status={status} onColorChange={onColorChange} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SECTION_LABEL_STYLE = {
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 9,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '1.2px',
|
||||
textTransform: 'uppercase' as const,
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="px-2 py-1">
|
||||
<span className="text-muted-foreground" style={SECTION_LABEL_STYLE}>{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatusOptionProps {
|
||||
highlightOffset: number
|
||||
highlightIndex: number
|
||||
colorEditingStatus: string | null
|
||||
onSelect: (status: string) => void
|
||||
onHighlight: (index: number) => void
|
||||
onToggleColor: (status: string) => void
|
||||
onColorChange: (status: string, colorKey: string) => void
|
||||
}
|
||||
|
||||
function StatusOptionList({ statuses, ...props }: StatusOptionProps & { statuses: string[] }) {
|
||||
return (
|
||||
<>
|
||||
{statuses.map((status, i) => (
|
||||
<StatusOption
|
||||
key={status}
|
||||
status={status}
|
||||
highlighted={props.highlightIndex === props.highlightOffset + i}
|
||||
onSelect={props.onSelect}
|
||||
onMouseEnter={() => props.onHighlight(props.highlightOffset + i)}
|
||||
colorEditing={props.colorEditingStatus === status}
|
||||
onToggleColor={props.onToggleColor}
|
||||
onColorChange={props.onColorChange}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function useStatusFiltering(query: string, vaultStatuses: string[]) {
|
||||
return useMemo(() => {
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const vaultSet = new Set(vaultStatuses.map(s => s.toLowerCase()))
|
||||
const suggested = SUGGESTED_STATUSES.filter(
|
||||
s => s.toLowerCase().includes(lowerQuery) && !vaultSet.has(s.toLowerCase()),
|
||||
)
|
||||
const vault = vaultStatuses.filter(s => s.toLowerCase().includes(lowerQuery))
|
||||
return { suggestedFiltered: suggested, vaultFiltered: vault, allFiltered: [...vault, ...suggested] }
|
||||
}, [query, vaultStatuses])
|
||||
}
|
||||
|
||||
interface KeyboardNavOptions {
|
||||
allFiltered: string[]
|
||||
totalOptions: number
|
||||
showCreateOption: boolean
|
||||
query: string
|
||||
onSave: (v: string) => void
|
||||
onCancel: () => void
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
function useStatusKeyboard(opts: KeyboardNavOptions) {
|
||||
const { allFiltered, totalOptions, showCreateOption, query, onSave, onCancel, listRef } = opts
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1)
|
||||
|
||||
const scrollIntoView = useCallback((index: number) => {
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
const items = list.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]')
|
||||
items[index]?.scrollIntoView({ block: 'nearest' })
|
||||
}, [listRef])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault()
|
||||
const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0
|
||||
setHighlightIndex(next)
|
||||
scrollIntoView(next)
|
||||
break
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault()
|
||||
const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1
|
||||
setHighlightIndex(prev)
|
||||
scrollIntoView(prev)
|
||||
break
|
||||
}
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
const trimmed = query.trim()
|
||||
if (highlightIndex >= 0 && highlightIndex < allFiltered.length) onSave(allFiltered[highlightIndex])
|
||||
else if (showCreateOption && highlightIndex === allFiltered.length) onSave(trimmed)
|
||||
else if (trimmed) onSave(trimmed)
|
||||
break
|
||||
}
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
break
|
||||
}
|
||||
},
|
||||
[highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, scrollIntoView],
|
||||
)
|
||||
|
||||
const resetHighlight = useCallback(() => setHighlightIndex(-1), [])
|
||||
|
||||
return { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight }
|
||||
}
|
||||
|
||||
export function StatusDropdown({
|
||||
vaultStatuses,
|
||||
onSave,
|
||||
@@ -63,188 +225,159 @@ export function StatusDropdown({
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1)
|
||||
const [colorEditingStatus, setColorEditingStatus] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
useLayoutEffect(() => {
|
||||
const node = dropdownRef.current
|
||||
if (!node) return
|
||||
const anchor = anchorRef.current?.parentElement
|
||||
if (!anchor) return
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const dropW = 208
|
||||
let left = rect.right - dropW
|
||||
if (left < 8) left = 8
|
||||
if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8
|
||||
node.style.top = `${rect.bottom + 4}px`
|
||||
node.style.left = `${left}px`
|
||||
}, [])
|
||||
|
||||
const { suggestedFiltered, vaultFiltered, allFiltered } = useMemo(() => {
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const vaultSet = new Set(vaultStatuses.map(s => s.toLowerCase()))
|
||||
useEffect(() => { inputRef.current?.focus() }, [])
|
||||
|
||||
const suggested = SUGGESTED_STATUSES.filter(
|
||||
s => s.toLowerCase().includes(lowerQuery) && !vaultSet.has(s.toLowerCase()),
|
||||
)
|
||||
|
||||
const vault = vaultStatuses.filter(s => s.toLowerCase().includes(lowerQuery))
|
||||
|
||||
return {
|
||||
suggestedFiltered: suggested,
|
||||
vaultFiltered: vault,
|
||||
allFiltered: [...vault, ...suggested],
|
||||
}
|
||||
}, [query, vaultStatuses])
|
||||
const { suggestedFiltered, vaultFiltered, allFiltered } = useStatusFiltering(query, vaultStatuses)
|
||||
|
||||
const showCreateOption = useMemo(() => {
|
||||
if (!query.trim()) return false
|
||||
const lowerQuery = query.trim().toLowerCase()
|
||||
return !allFiltered.some(s => s.toLowerCase() === lowerQuery)
|
||||
return !allFiltered.some(s => s.toLowerCase() === query.trim().toLowerCase())
|
||||
}, [query, allFiltered])
|
||||
|
||||
const totalOptions = allFiltered.length + (showCreateOption ? 1 : 0)
|
||||
|
||||
const scrollHighlightedIntoView = useCallback((index: number) => {
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
const items = list.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]')
|
||||
items[index]?.scrollIntoView({ block: 'nearest' })
|
||||
const { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight } =
|
||||
useStatusKeyboard({ allFiltered, totalOptions, showCreateOption, query, onSave, onCancel, listRef })
|
||||
|
||||
const handleToggleColor = useCallback((status: string) => {
|
||||
setColorEditingStatus(prev => prev === status ? null : status)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0
|
||||
setHighlightIndex(next)
|
||||
scrollHighlightedIntoView(next)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1
|
||||
setHighlightIndex(prev)
|
||||
scrollHighlightedIntoView(prev)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (highlightIndex >= 0 && highlightIndex < allFiltered.length) {
|
||||
onSave(allFiltered[highlightIndex])
|
||||
} else if (showCreateOption && highlightIndex === allFiltered.length) {
|
||||
onSave(query.trim())
|
||||
} else if (query.trim()) {
|
||||
onSave(query.trim())
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
},
|
||||
[highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, scrollHighlightedIntoView],
|
||||
)
|
||||
const handleColorChange = useCallback((status: string, colorKey: string) => {
|
||||
const currentKey = getStatusColorKey(status)
|
||||
setStatusColor(status, currentKey === colorKey ? null : colorKey)
|
||||
setColorEditingStatus(null)
|
||||
}, [])
|
||||
|
||||
const handleQueryChange = useCallback((value: string) => {
|
||||
setQuery(value)
|
||||
resetHighlight()
|
||||
}, [resetHighlight])
|
||||
|
||||
const optionProps: StatusOptionProps = {
|
||||
highlightOffset: 0,
|
||||
highlightIndex,
|
||||
colorEditingStatus,
|
||||
onSelect: onSave,
|
||||
onHighlight: setHighlightIndex,
|
||||
onToggleColor: handleToggleColor,
|
||||
onColorChange: handleColorChange,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" data-testid="status-dropdown">
|
||||
{/* Backdrop to close on outside click */}
|
||||
<div className="fixed inset-0 z-[12000]" onClick={onCancel} data-testid="status-dropdown-backdrop" />
|
||||
|
||||
<div
|
||||
className="absolute right-0 top-full z-[12001] mt-1 w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
|
||||
data-testid="status-dropdown-popover"
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="border-b border-border px-2 py-1.5">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full border-none bg-transparent text-[12px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
placeholder="Type a status..."
|
||||
value={query}
|
||||
onChange={e => {
|
||||
setQuery(e.target.value)
|
||||
setHighlightIndex(-1)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="status-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
|
||||
{vaultFiltered.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1">
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 9,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '1.2px',
|
||||
textTransform: 'uppercase' as const,
|
||||
}}
|
||||
>
|
||||
From vault
|
||||
</span>
|
||||
</div>
|
||||
{vaultFiltered.map((status, i) => (
|
||||
<StatusOption
|
||||
key={status}
|
||||
status={status}
|
||||
highlighted={highlightIndex === i}
|
||||
onSelect={onSave}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
/>
|
||||
))}
|
||||
<span ref={anchorRef} data-testid="status-dropdown">
|
||||
{createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[12000]" onClick={onCancel} data-testid="status-dropdown-backdrop" />
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[12001] w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
|
||||
data-testid="status-dropdown-popover"
|
||||
>
|
||||
<div className="border-b border-border px-2 py-1.5">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full border-none bg-transparent text-[12px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
placeholder="Type a status..."
|
||||
value={query}
|
||||
onChange={e => handleQueryChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="status-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestedFiltered.length > 0 && (
|
||||
<div>
|
||||
{vaultFiltered.length > 0 && (
|
||||
<div className="my-1 h-px bg-border" />
|
||||
)}
|
||||
<div className="px-2 py-1">
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 9,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '1.2px',
|
||||
textTransform: 'uppercase' as const,
|
||||
}}
|
||||
>
|
||||
Suggested
|
||||
</span>
|
||||
</div>
|
||||
{suggestedFiltered.map((status, i) => (
|
||||
<StatusOption
|
||||
key={status}
|
||||
status={status}
|
||||
highlighted={highlightIndex === vaultFiltered.length + i}
|
||||
onSelect={onSave}
|
||||
onMouseEnter={() => setHighlightIndex(vaultFiltered.length + i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateOption && (
|
||||
<>
|
||||
{allFiltered.length > 0 && <div className="my-1 h-px bg-border" />}
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
backgroundColor:
|
||||
highlightIndex === allFiltered.length ? 'var(--muted)' : 'transparent',
|
||||
color: 'var(--muted-foreground)',
|
||||
}}
|
||||
onClick={() => onSave(query.trim())}
|
||||
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
|
||||
<VaultSection statuses={vaultFiltered} {...optionProps} />
|
||||
<SuggestedSection
|
||||
statuses={suggestedFiltered}
|
||||
showDivider={vaultFiltered.length > 0}
|
||||
{...optionProps}
|
||||
highlightOffset={vaultFiltered.length}
|
||||
/>
|
||||
<CreateSection
|
||||
show={showCreateOption}
|
||||
query={query}
|
||||
showDivider={allFiltered.length > 0}
|
||||
highlighted={highlightIndex === allFiltered.length}
|
||||
onSave={onSave}
|
||||
onMouseEnter={() => setHighlightIndex(allFiltered.length)}
|
||||
data-testid="status-create-option"
|
||||
>
|
||||
Create <StatusPill status={query.trim()} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{allFiltered.length === 0 && !showCreateOption && (
|
||||
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
|
||||
No matching statuses
|
||||
/>
|
||||
{allFiltered.length === 0 && !showCreateOption && (
|
||||
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
|
||||
No matching statuses
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function VaultSection({ statuses, ...props }: StatusOptionProps & { statuses: string[] }) {
|
||||
if (statuses.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>From vault</SectionLabel>
|
||||
<StatusOptionList statuses={statuses} {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SuggestedSection({ statuses, showDivider, ...props }: StatusOptionProps & { statuses: string[]; showDivider: boolean }) {
|
||||
if (statuses.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
{showDivider && <div className="my-1 h-px bg-border" />}
|
||||
<SectionLabel>Suggested</SectionLabel>
|
||||
<StatusOptionList statuses={statuses} {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateSection({ show, query, showDivider, highlighted, onSave, onMouseEnter }: {
|
||||
show: boolean; query: string; showDivider: boolean; highlighted: boolean
|
||||
onSave: (v: string) => void; onMouseEnter: () => void
|
||||
}) {
|
||||
if (!show) return null
|
||||
const trimmed = query.trim()
|
||||
return (
|
||||
<>
|
||||
{showDivider && <div className="my-1 h-px bg-border" />}
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
backgroundColor: highlighted ? 'var(--muted)' : 'transparent',
|
||||
color: 'var(--muted-foreground)',
|
||||
}}
|
||||
onClick={() => onSave(trimmed)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
data-testid="status-create-option"
|
||||
>
|
||||
Create <StatusPill status={trimmed} />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) {
|
||||
}
|
||||
|
||||
const STATUS_DOT: Record<string, { color: string; testId: string; title: string; pulse?: boolean }> = {
|
||||
unsaved: { color: 'var(--accent-blue, #3b82f6)', testId: 'tab-unsaved-indicator', title: 'Unsaved — Cmd+S to save' },
|
||||
pendingSave: { color: 'var(--accent-green)', testId: 'tab-pending-save-indicator', title: 'Saving to disk…', pulse: true },
|
||||
new: { color: 'var(--accent-green)', testId: 'tab-new-indicator', title: 'New (uncommitted)' },
|
||||
modified: { color: 'var(--accent-orange)', testId: 'tab-modified-indicator', title: 'Modified (uncommitted)' },
|
||||
@@ -235,7 +236,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
|
||||
{isEditing ? (
|
||||
<InlineTabEdit initialValue={tab.entry.title} onSave={onRenameSave} onCancel={onRenameCancel} />
|
||||
) : (
|
||||
<span className="truncate" onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
|
||||
<span className="truncate" style={noteStatus === 'unsaved' ? { fontStyle: 'italic' } : undefined} onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
|
||||
{tab.entry.title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
149
src/components/TagsDropdown.test.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { TagPill, TagsDropdown } from './TagsDropdown'
|
||||
|
||||
describe('TagPill', () => {
|
||||
it('renders tag text', () => {
|
||||
render(<TagPill tag="React" />)
|
||||
const pill = screen.getByTitle('React')
|
||||
expect(pill).toBeInTheDocument()
|
||||
expect(pill.textContent).toBe('React')
|
||||
})
|
||||
|
||||
it('renders with default style (blue)', () => {
|
||||
render(<TagPill tag="Unknown" />)
|
||||
const pill = screen.getByTitle('Unknown')
|
||||
expect(pill.style.backgroundColor).toBe('var(--accent-blue-light)')
|
||||
expect(pill.style.color).toBe('var(--accent-blue)')
|
||||
})
|
||||
|
||||
it('applies truncate for long names', () => {
|
||||
render(<TagPill tag="Very Long Tag Name That Should Truncate" />)
|
||||
const pill = screen.getByTitle('Very Long Tag Name That Should Truncate')
|
||||
expect(pill.className).toContain('truncate')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TagsDropdown', () => {
|
||||
const onToggle = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
const defaultProps = {
|
||||
selectedTags: ['React', 'Tauri'],
|
||||
vaultTags: ['React', 'TypeScript', 'Tauri', 'Vite'],
|
||||
onToggle,
|
||||
onClose,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders dropdown with search input', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
expect(screen.getByTestId('tags-dropdown')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tags-search-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows vault tags as options', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
expect(screen.getByTestId('tag-option-React')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tag-option-TypeScript')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tag-option-Tauri')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tag-option-Vite')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "From vault" section label', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
expect(screen.getByText('From vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows checkmark for selected tags', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
// React and Tauri are selected — their check marks should show
|
||||
const reactOption = screen.getByTestId('tag-option-React').closest('div')!
|
||||
const checkSpans = reactOption.querySelectorAll('span')
|
||||
const hasCheck = Array.from(checkSpans).some(s => s.textContent === '\u2713')
|
||||
expect(hasCheck).toBe(true)
|
||||
})
|
||||
|
||||
it('calls onToggle when a tag option is clicked', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('tag-option-TypeScript'))
|
||||
expect(onToggle).toHaveBeenCalledWith('TypeScript')
|
||||
})
|
||||
|
||||
it('calls onClose when backdrop is clicked', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('tags-dropdown-backdrop'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('filters tags by search query', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
fireEvent.change(screen.getByTestId('tags-search-input'), { target: { value: 'type' } })
|
||||
expect(screen.getByTestId('tag-option-TypeScript')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tag-option-Vite')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Create" option when query does not match any vault tag', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
fireEvent.change(screen.getByTestId('tags-search-input'), { target: { value: 'NewTag' } })
|
||||
expect(screen.getByTestId('tag-create-option')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tag-create-option').textContent).toContain('Create')
|
||||
expect(screen.getByTestId('tag-create-option').textContent).toContain('NewTag')
|
||||
})
|
||||
|
||||
it('does not show create option when query matches an existing tag (case-insensitive)', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
fireEvent.change(screen.getByTestId('tags-search-input'), { target: { value: 'react' } })
|
||||
expect(screen.queryByTestId('tag-create-option')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onToggle with new tag name when Create option is clicked', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
fireEvent.change(screen.getByTestId('tags-search-input'), { target: { value: 'Flutter' } })
|
||||
fireEvent.click(screen.getByTestId('tag-create-option'))
|
||||
expect(onToggle).toHaveBeenCalledWith('Flutter')
|
||||
})
|
||||
|
||||
it('shows "No matching tags" when filter yields no results and query is empty-ish', () => {
|
||||
render(<TagsDropdown {...defaultProps} vaultTags={[]} />)
|
||||
expect(screen.getByText('No matching tags')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows color swatch for each tag option', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
expect(screen.getByTestId('tag-color-swatch-React')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tag-color-swatch-TypeScript')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens color picker when color swatch is clicked', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('tag-color-swatch-React'))
|
||||
expect(screen.getByTestId('tag-color-picker-React')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates options with arrow keys', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
const input = screen.getByTestId('tags-search-input')
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onToggle).toHaveBeenCalledWith('React')
|
||||
})
|
||||
|
||||
it('closes dropdown on Escape key', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
const input = screen.getByTestId('tags-search-input')
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates new tag on Enter when query is typed and no highlight', () => {
|
||||
render(<TagsDropdown {...defaultProps} />)
|
||||
const input = screen.getByTestId('tags-search-input')
|
||||
fireEvent.change(input, { target: { value: 'Brand New' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onToggle).toHaveBeenCalledWith('Brand New')
|
||||
})
|
||||
})
|
||||
302
src/components/TagsDropdown.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { getTagStyle, setTagColor, getTagColorKey } from '../utils/tagStyles'
|
||||
import { ACCENT_COLORS } from '../utils/typeColors'
|
||||
|
||||
export function TagPill({ tag, className }: { tag: string; className?: string }) {
|
||||
const style = getTagStyle(tag)
|
||||
return (
|
||||
<span
|
||||
className={`inline-block min-w-0 truncate${className ? ` ${className}` : ''}`}
|
||||
style={{
|
||||
backgroundColor: style.bg,
|
||||
color: style.color,
|
||||
borderRadius: 16,
|
||||
padding: '1px 6px',
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '1.2px',
|
||||
textTransform: 'uppercase' as const,
|
||||
maxWidth: 160,
|
||||
}}
|
||||
title={tag}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ColorPickerRow({ tag, onColorChange }: { tag: string; onColorChange: (tag: string, colorKey: string) => void }) {
|
||||
const currentKey = getTagColorKey(tag)
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-3 py-1.5" data-testid={`tag-color-picker-${tag}`}>
|
||||
{ACCENT_COLORS.map(c => (
|
||||
<button
|
||||
key={c.key}
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0 transition-transform hover:scale-125"
|
||||
style={{ backgroundColor: c.css }}
|
||||
onClick={(e) => { e.stopPropagation(); onColorChange(tag, c.key) }}
|
||||
title={c.label}
|
||||
data-testid={`tag-color-option-${c.key}`}
|
||||
>
|
||||
{currentKey === c.key && (
|
||||
<span style={{ color: 'white', fontSize: 8, lineHeight: 1 }}>{'\u2713'}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TagOption({
|
||||
tag, selected, highlighted, onToggle, onMouseEnter,
|
||||
colorEditing, onToggleColor, onColorChange,
|
||||
}: {
|
||||
tag: string; selected: boolean; highlighted: boolean
|
||||
onToggle: (tag: string) => void; onMouseEnter: () => void
|
||||
colorEditing: boolean
|
||||
onToggleColor: (tag: string) => void; onColorChange: (tag: string, colorKey: string) => void
|
||||
}) {
|
||||
const style = getTagStyle(tag)
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex w-full items-center gap-1 px-2 py-1 transition-colors"
|
||||
style={{ borderRadius: 4, backgroundColor: highlighted ? 'var(--muted)' : 'transparent' }}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 border-none bg-transparent p-0 text-left"
|
||||
onClick={() => onToggle(tag)}
|
||||
data-testid={`tag-option-${tag}`}
|
||||
>
|
||||
<span className="w-3.5 text-center text-[10px]" style={{ color: style.color }}>
|
||||
{selected ? '\u2713' : ''}
|
||||
</span>
|
||||
<TagPill tag={tag} />
|
||||
</button>
|
||||
<button
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0"
|
||||
style={{ backgroundColor: style.color }}
|
||||
onClick={() => onToggleColor(tag)}
|
||||
title="Change color"
|
||||
data-testid={`tag-color-swatch-${tag}`}
|
||||
/>
|
||||
</div>
|
||||
{colorEditing && <ColorPickerRow tag={tag} onColorChange={onColorChange} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SECTION_LABEL_STYLE = {
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 9,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '1.2px',
|
||||
textTransform: 'uppercase' as const,
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="px-2 py-1">
|
||||
<span className="text-muted-foreground" style={SECTION_LABEL_STYLE}>{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useTagFiltering(query: string, vaultTags: string[]) {
|
||||
return useMemo(() => {
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const filtered = vaultTags.filter(t => t.toLowerCase().includes(lowerQuery))
|
||||
return { filtered }
|
||||
}, [query, vaultTags])
|
||||
}
|
||||
|
||||
function useTagKeyboard(opts: {
|
||||
filtered: string[]; totalOptions: number; showCreateOption: boolean
|
||||
query: string; selectedTags: Set<string>
|
||||
onToggle: (tag: string) => void; onClose: () => void
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
const { filtered, totalOptions, showCreateOption, query, selectedTags, onToggle, onClose, listRef } = opts
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1)
|
||||
|
||||
const scrollIntoView = useCallback((index: number) => {
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
const items = list.querySelectorAll('[data-testid^="tag-option-"], [data-testid="tag-create-option"]')
|
||||
items[index]?.scrollIntoView({ block: 'nearest' })
|
||||
}, [listRef])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault()
|
||||
const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0
|
||||
setHighlightIndex(next)
|
||||
scrollIntoView(next)
|
||||
break
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault()
|
||||
const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1
|
||||
setHighlightIndex(prev)
|
||||
scrollIntoView(prev)
|
||||
break
|
||||
}
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
const trimmed = query.trim()
|
||||
if (highlightIndex >= 0 && highlightIndex < filtered.length) {
|
||||
onToggle(filtered[highlightIndex])
|
||||
} else if (showCreateOption && highlightIndex === filtered.length && trimmed) {
|
||||
onToggle(trimmed)
|
||||
} else if (trimmed && !selectedTags.has(trimmed)) {
|
||||
onToggle(trimmed)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
break
|
||||
}
|
||||
},
|
||||
[highlightIndex, totalOptions, filtered, showCreateOption, query, selectedTags, onToggle, onClose, scrollIntoView],
|
||||
)
|
||||
|
||||
const resetHighlight = useCallback(() => setHighlightIndex(-1), [])
|
||||
|
||||
return { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight }
|
||||
}
|
||||
|
||||
export function TagsDropdown({
|
||||
selectedTags, vaultTags, onToggle, onClose,
|
||||
}: {
|
||||
selectedTags: string[]; vaultTags: string[]
|
||||
onToggle: (tag: string) => void; onClose: () => void
|
||||
}) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [colorEditingTag, setColorEditingTag] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedTags), [selectedTags])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = dropdownRef.current
|
||||
if (!node) return
|
||||
const anchor = anchorRef.current?.parentElement
|
||||
if (!anchor) return
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const dropW = 208
|
||||
let left = rect.right - dropW
|
||||
if (left < 8) left = 8
|
||||
if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8
|
||||
node.style.top = `${rect.bottom + 4}px`
|
||||
node.style.left = `${left}px`
|
||||
}, [])
|
||||
|
||||
useEffect(() => { inputRef.current?.focus() }, [])
|
||||
|
||||
const { filtered } = useTagFiltering(query, vaultTags)
|
||||
|
||||
const showCreateOption = useMemo(() => {
|
||||
if (!query.trim()) return false
|
||||
return !filtered.some(t => t.toLowerCase() === query.trim().toLowerCase())
|
||||
}, [query, filtered])
|
||||
|
||||
const totalOptions = filtered.length + (showCreateOption ? 1 : 0)
|
||||
|
||||
const { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight } =
|
||||
useTagKeyboard({ filtered, totalOptions, showCreateOption, query, selectedTags: selectedSet, onToggle, onClose, listRef })
|
||||
|
||||
const handleToggleColor = useCallback((tag: string) => {
|
||||
setColorEditingTag(prev => prev === tag ? null : tag)
|
||||
}, [])
|
||||
|
||||
const handleColorChange = useCallback((tag: string, colorKey: string) => {
|
||||
const currentKey = getTagColorKey(tag)
|
||||
setTagColor(tag, currentKey === colorKey ? null : colorKey)
|
||||
setColorEditingTag(null)
|
||||
}, [])
|
||||
|
||||
const handleQueryChange = useCallback((value: string) => {
|
||||
setQuery(value)
|
||||
resetHighlight()
|
||||
}, [resetHighlight])
|
||||
|
||||
return (
|
||||
<span ref={anchorRef} data-testid="tags-dropdown">
|
||||
{createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[12000]" onClick={onClose} data-testid="tags-dropdown-backdrop" />
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[12001] w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
|
||||
data-testid="tags-dropdown-popover"
|
||||
>
|
||||
<div className="border-b border-border px-2 py-1.5">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full border-none bg-transparent text-[12px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
placeholder="Type a tag..."
|
||||
value={query}
|
||||
onChange={e => handleQueryChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="tags-search-input"
|
||||
/>
|
||||
</div>
|
||||
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
|
||||
{filtered.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>From vault</SectionLabel>
|
||||
{filtered.map((tag, i) => (
|
||||
<TagOption
|
||||
key={tag} tag={tag}
|
||||
selected={selectedSet.has(tag)}
|
||||
highlighted={highlightIndex === i}
|
||||
onToggle={onToggle}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
colorEditing={colorEditingTag === tag}
|
||||
onToggleColor={handleToggleColor}
|
||||
onColorChange={handleColorChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showCreateOption && (
|
||||
<>
|
||||
{filtered.length > 0 && <div className="my-1 h-px bg-border" />}
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
backgroundColor: highlightIndex === filtered.length ? 'var(--muted)' : 'transparent',
|
||||
color: 'var(--muted-foreground)',
|
||||
}}
|
||||
onClick={() => onToggle(query.trim())}
|
||||
onMouseEnter={() => setHighlightIndex(filtered.length)}
|
||||
data-testid="tag-create-option"
|
||||
>
|
||||
Create <TagPill tag={query.trim()} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{filtered.length === 0 && !showCreateOption && (
|
||||
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
|
||||
No matching tags
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
110
src/components/WelcomeScreen.test.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { WelcomeScreen } from './WelcomeScreen'
|
||||
|
||||
const defaultProps = {
|
||||
mode: 'welcome' as const,
|
||||
defaultVaultPath: '~/Documents/Laputa',
|
||||
onCreateVault: vi.fn(),
|
||||
onOpenFolder: vi.fn(),
|
||||
creating: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
describe('WelcomeScreen', () => {
|
||||
describe('welcome mode', () => {
|
||||
it('renders welcome title and subtitle', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByText('Welcome to Laputa')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Wiki-linked knowledge management/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows create vault and open folder buttons', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Create Getting Started vault')
|
||||
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open an existing folder')
|
||||
})
|
||||
|
||||
it('shows default vault path hint', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByText(/will be created in/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/~\/Documents\/Laputa/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCreateVault when create button is clicked', () => {
|
||||
const onCreateVault = vi.fn()
|
||||
render(<WelcomeScreen {...defaultProps} onCreateVault={onCreateVault} />)
|
||||
fireEvent.click(screen.getByTestId('welcome-create-vault'))
|
||||
expect(onCreateVault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onOpenFolder when open folder button is clicked', () => {
|
||||
const onOpenFolder = vi.fn()
|
||||
render(<WelcomeScreen {...defaultProps} onOpenFolder={onOpenFolder} />)
|
||||
fireEvent.click(screen.getByTestId('welcome-open-folder'))
|
||||
expect(onOpenFolder).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables buttons while creating', () => {
|
||||
render(<WelcomeScreen {...defaultProps} creating={true} />)
|
||||
expect(screen.getByTestId('welcome-create-vault')).toBeDisabled()
|
||||
expect(screen.getByTestId('welcome-open-folder')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows loading text while creating', () => {
|
||||
render(<WelcomeScreen {...defaultProps} creating={true} />)
|
||||
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Creating vault…')
|
||||
})
|
||||
|
||||
it('shows error message when error is set', () => {
|
||||
render(<WelcomeScreen {...defaultProps} error="Permission denied" />)
|
||||
expect(screen.getByTestId('welcome-error')).toHaveTextContent('Permission denied')
|
||||
})
|
||||
|
||||
it('does not show error when error is null', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.queryByTestId('welcome-error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show path badge in welcome mode', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.queryByText('~/Laputa')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('vault-missing mode', () => {
|
||||
const missingProps = {
|
||||
...defaultProps,
|
||||
mode: 'vault-missing' as const,
|
||||
missingPath: '~/Laputa',
|
||||
}
|
||||
|
||||
it('renders vault not found title', () => {
|
||||
render(<WelcomeScreen {...missingProps} />)
|
||||
expect(screen.getByText('Vault not found')).toBeInTheDocument()
|
||||
expect(screen.getByText(/could not be found on disk/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the missing vault path in a badge', () => {
|
||||
render(<WelcomeScreen {...missingProps} />)
|
||||
expect(screen.getByText('~/Laputa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Choose a different folder" instead of "Open an existing folder"', () => {
|
||||
render(<WelcomeScreen {...missingProps} />)
|
||||
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Choose a different folder')
|
||||
})
|
||||
|
||||
it('does not show vault path hint in vault-missing mode', () => {
|
||||
render(<WelcomeScreen {...missingProps} />)
|
||||
expect(screen.queryByText(/will be created in/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('data-testid', () => {
|
||||
it('has welcome-screen container testid', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
206
src/components/WelcomeScreen.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useState } from 'react'
|
||||
import { FolderOpen, Plus, AlertTriangle, Loader2 } from 'lucide-react'
|
||||
|
||||
interface WelcomeScreenProps {
|
||||
mode: 'welcome' | 'vault-missing'
|
||||
missingPath?: string
|
||||
defaultVaultPath: string
|
||||
onCreateVault: () => void
|
||||
onOpenFolder: () => void
|
||||
creating: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const CONTAINER_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--sidebar)',
|
||||
}
|
||||
|
||||
const CARD_STYLE: React.CSSProperties = {
|
||||
width: 520,
|
||||
background: 'var(--background)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--border)',
|
||||
padding: 48,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 24,
|
||||
}
|
||||
|
||||
const ICON_WRAP_STYLE: React.CSSProperties = {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}
|
||||
|
||||
const TITLE_STYLE: React.CSSProperties = {
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
letterSpacing: -0.5,
|
||||
color: 'var(--foreground)',
|
||||
textAlign: 'center',
|
||||
margin: 0,
|
||||
}
|
||||
|
||||
const SUBTITLE_STYLE: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--muted-foreground)',
|
||||
textAlign: 'center',
|
||||
margin: 0,
|
||||
}
|
||||
|
||||
const DIVIDER_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: 1,
|
||||
background: 'var(--border)',
|
||||
}
|
||||
|
||||
const PRIMARY_BTN_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: 44,
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: 'var(--primary)',
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
}
|
||||
|
||||
const SECONDARY_BTN_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: 44,
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--background)',
|
||||
color: 'var(--foreground)',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
}
|
||||
|
||||
const HINT_STYLE: React.CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: 'var(--muted-foreground)',
|
||||
textAlign: 'center',
|
||||
margin: 0,
|
||||
}
|
||||
|
||||
const PATH_BADGE_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
background: 'var(--sidebar)',
|
||||
borderRadius: 6,
|
||||
padding: '8px 12px',
|
||||
textAlign: 'center',
|
||||
}
|
||||
|
||||
const ERROR_STYLE: React.CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: 'var(--destructive, #e03e3e)',
|
||||
textAlign: 'center',
|
||||
margin: 0,
|
||||
}
|
||||
|
||||
export function WelcomeScreen({ mode, missingPath, defaultVaultPath, onCreateVault, onOpenFolder, creating, error }: WelcomeScreenProps) {
|
||||
const [hoverPrimary, setHoverPrimary] = useState(false)
|
||||
const [hoverSecondary, setHoverSecondary] = useState(false)
|
||||
|
||||
const isWelcome = mode === 'welcome'
|
||||
|
||||
return (
|
||||
<div style={CONTAINER_STYLE} data-testid="welcome-screen">
|
||||
<div style={CARD_STYLE}>
|
||||
<div
|
||||
style={{
|
||||
...ICON_WRAP_STYLE,
|
||||
background: isWelcome ? 'var(--accent-blue-light, #EBF4FF)' : 'var(--accent-yellow-light, #FFF3E0)',
|
||||
}}
|
||||
>
|
||||
{isWelcome
|
||||
? <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}>✦</span>
|
||||
: <AlertTriangle size={28} style={{ color: 'var(--accent-orange)' }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h1 style={TITLE_STYLE}>
|
||||
{isWelcome ? 'Welcome to Laputa' : 'Vault not found'}
|
||||
</h1>
|
||||
<p style={{ ...SUBTITLE_STYLE, marginTop: 8 }}>
|
||||
{isWelcome
|
||||
? 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.'
|
||||
: 'The vault folder could not be found on disk.\nIt may have been moved or deleted.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isWelcome && missingPath && (
|
||||
<div style={PATH_BADGE_STYLE}>
|
||||
<code style={{ fontSize: 12, color: 'var(--muted-foreground)', fontFamily: 'var(--font-mono, monospace)' }}>
|
||||
{missingPath}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={DIVIDER_STYLE} />
|
||||
|
||||
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<button
|
||||
style={{
|
||||
...PRIMARY_BTN_STYLE,
|
||||
opacity: creating ? 0.7 : hoverPrimary ? 0.9 : 1,
|
||||
}}
|
||||
onClick={onCreateVault}
|
||||
disabled={creating}
|
||||
onMouseEnter={() => setHoverPrimary(true)}
|
||||
onMouseLeave={() => setHoverPrimary(false)}
|
||||
data-testid="welcome-create-vault"
|
||||
>
|
||||
{creating ? <Loader2 size={16} className="animate-spin" /> : <Plus size={16} />}
|
||||
{creating ? 'Creating vault…' : 'Create Getting Started vault'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
style={{
|
||||
...SECONDARY_BTN_STYLE,
|
||||
background: hoverSecondary ? 'var(--sidebar)' : 'var(--background)',
|
||||
}}
|
||||
onClick={onOpenFolder}
|
||||
disabled={creating}
|
||||
onMouseEnter={() => setHoverSecondary(true)}
|
||||
onMouseLeave={() => setHoverSecondary(false)}
|
||||
data-testid="welcome-open-folder"
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
{isWelcome ? 'Open an existing folder' : 'Choose a different folder'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p style={ERROR_STYLE} data-testid="welcome-error">{error}</p>}
|
||||
|
||||
{isWelcome && !error && (
|
||||
<p style={HINT_STYLE}>
|
||||
The Getting Started vault will be created in {defaultVaultPath}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
collisionPadding = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
@@ -27,8 +28,9 @@ function PopoverContent({
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[12000] w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -53,6 +53,7 @@ function SelectContent({
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
collisionPadding = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
@@ -60,13 +61,14 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[12000] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
collisionPadding={collisionPadding}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
|
||||
@@ -21,6 +21,7 @@ interface AppCommandsConfig {
|
||||
onCommandPalette: () => void
|
||||
onSearch: () => void
|
||||
onCreateNote: () => void
|
||||
onCreateNoteOfType: (type: string) => void
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
onTrashNote: (path: string) => void
|
||||
@@ -29,6 +30,10 @@ interface AppCommandsConfig {
|
||||
onCommitPush: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
zoomLevel: number
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
onCloseTab: (path: string) => void
|
||||
onSwitchTab: (path: string) => void
|
||||
@@ -52,6 +57,9 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onTrashNote: config.onTrashNote,
|
||||
onArchiveNote: config.onArchiveNote,
|
||||
onSetViewMode: config.onSetViewMode,
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
onZoomReset: config.onZoomReset,
|
||||
onGoBack: config.onGoBack,
|
||||
onGoForward: config.onGoForward,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
@@ -66,6 +74,9 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onOpenSettings: config.onOpenSettings,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
onCommandPalette: config.onCommandPalette,
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
onZoomReset: config.onZoomReset,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
activeTabPath: config.activeTabPath,
|
||||
@@ -77,6 +88,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
modifiedCount: config.modifiedCount,
|
||||
onQuickOpen: config.onQuickOpen,
|
||||
onCreateNote: config.onCreateNote,
|
||||
onCreateNoteOfType: config.onCreateNoteOfType,
|
||||
onSave: config.onSave,
|
||||
onOpenSettings: config.onOpenSettings,
|
||||
onTrashNote: config.onTrashNote,
|
||||
@@ -85,6 +97,10 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onCommitPush: config.onCommitPush,
|
||||
onSetViewMode: config.onSetViewMode,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
onZoomReset: config.onZoomReset,
|
||||
zoomLevel: config.zoomLevel,
|
||||
onSelect: config.onSelect,
|
||||
onCloseTab: config.onCloseTab,
|
||||
onGoBack: config.onGoBack,
|
||||
|
||||
@@ -26,6 +26,9 @@ function makeActions() {
|
||||
onTrashNote: vi.fn(),
|
||||
onArchiveNote: vi.fn(),
|
||||
onSetViewMode: vi.fn(),
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
onZoomReset: vi.fn(),
|
||||
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
|
||||
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
|
||||
}
|
||||
@@ -143,4 +146,32 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onCommandPalette).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+= triggers zoom in', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('=', { metaKey: true })
|
||||
expect(actions.onZoomIn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd++ triggers zoom in', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('+', { metaKey: true })
|
||||
expect(actions.onZoomIn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+- triggers zoom out', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('-', { metaKey: true })
|
||||
expect(actions.onZoomOut).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+0 triggers zoom reset', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('0', { metaKey: true })
|
||||
expect(actions.onZoomReset).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,9 @@ interface KeyboardActions {
|
||||
onTrashNote: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
onGoBack?: () => void
|
||||
onGoForward?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
@@ -58,7 +61,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
|
||||
|
||||
export function useAppKeyboard({
|
||||
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
|
||||
onSetViewMode, onGoBack, onGoForward, activeTabPathRef, handleCloseTabRef,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, activeTabPathRef, handleCloseTabRef,
|
||||
}: KeyboardActions) {
|
||||
useEffect(() => {
|
||||
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
|
||||
@@ -78,6 +81,10 @@ export function useAppKeyboard({
|
||||
Delete: withActiveTab(onTrashNote),
|
||||
'[': () => onGoBack?.(),
|
||||
']': () => onGoForward?.(),
|
||||
'=': onZoomIn,
|
||||
'+': onZoomIn,
|
||||
'-': onZoomOut,
|
||||
'0': onZoomReset,
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -93,5 +100,5 @@ export function useAppKeyboard({
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onGoBack, onGoForward])
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward])
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useCommandRegistry, groupSortKey } from './useCommandRegistry'
|
||||
import { useCommandRegistry, groupSortKey, pluralizeType, extractVaultTypes } from './useCommandRegistry'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
@@ -37,6 +37,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
modifiedCount: 0,
|
||||
onQuickOpen: vi.fn(),
|
||||
onCreateNote: vi.fn(),
|
||||
onCreateNoteOfType: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onTrashNote: vi.fn(),
|
||||
@@ -45,6 +46,10 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
onCommitPush: vi.fn(),
|
||||
onSetViewMode: vi.fn(),
|
||||
onToggleInspector: vi.fn(),
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
onZoomReset: vi.fn(),
|
||||
zoomLevel: 100,
|
||||
onSelect: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
...overrides,
|
||||
@@ -140,6 +145,199 @@ describe('useCommandRegistry', () => {
|
||||
result.current.find(c => c.id === 'view-editor')!.execute()
|
||||
expect(onSetViewMode).toHaveBeenCalledWith('editor-only')
|
||||
})
|
||||
|
||||
it('zoom-in is enabled when below max zoom', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 100 })))
|
||||
expect(result.current.find(c => c.id === 'zoom-in')!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('zoom-in is disabled at max zoom', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 150 })))
|
||||
expect(result.current.find(c => c.id === 'zoom-in')!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('zoom-out is disabled at min zoom', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 80 })))
|
||||
expect(result.current.find(c => c.id === 'zoom-out')!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('zoom-reset is disabled at 100%', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 100 })))
|
||||
expect(result.current.find(c => c.id === 'zoom-reset')!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('zoom-reset is enabled when not at 100%', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 120 })))
|
||||
expect(result.current.find(c => c.id === 'zoom-reset')!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('zoom-in label shows current zoom level', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 120 })))
|
||||
expect(result.current.find(c => c.id === 'zoom-in')!.label).toContain('120%')
|
||||
})
|
||||
|
||||
describe('type-aware commands', () => {
|
||||
it('generates "New [Type]" commands from vault entries', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' }),
|
||||
makeEntry({ path: '/vault/person/alice.md', isA: 'Person' }),
|
||||
]
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
|
||||
const newEvent = result.current.find(c => c.id === 'new-event')
|
||||
const newPerson = result.current.find(c => c.id === 'new-person')
|
||||
expect(newEvent).toBeDefined()
|
||||
expect(newEvent!.label).toBe('New Event')
|
||||
expect(newEvent!.group).toBe('Note')
|
||||
expect(newPerson).toBeDefined()
|
||||
expect(newPerson!.label).toBe('New Person')
|
||||
})
|
||||
|
||||
it('generates "List [Type]" commands with pluralized names', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' }),
|
||||
makeEntry({ path: '/vault/person/alice.md', isA: 'Person' }),
|
||||
]
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
|
||||
const listEvents = result.current.find(c => c.id === 'list-event')
|
||||
const listPeople = result.current.find(c => c.id === 'list-person')
|
||||
expect(listEvents).toBeDefined()
|
||||
expect(listEvents!.label).toBe('List Events')
|
||||
expect(listEvents!.group).toBe('Navigation')
|
||||
expect(listPeople).toBeDefined()
|
||||
expect(listPeople!.label).toBe('List People')
|
||||
})
|
||||
|
||||
it('calls onCreateNoteOfType when "New [Type]" executes', () => {
|
||||
const onCreateNoteOfType = vi.fn()
|
||||
const entries = [makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' })]
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries, onCreateNoteOfType })))
|
||||
result.current.find(c => c.id === 'new-event')!.execute()
|
||||
expect(onCreateNoteOfType).toHaveBeenCalledWith('Event')
|
||||
})
|
||||
|
||||
it('calls onSelect with sectionGroup when "List [Type]" executes', () => {
|
||||
const onSelect = vi.fn()
|
||||
const entries = [makeEntry({ path: '/vault/person/alice.md', isA: 'Person' })]
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries, onSelect })))
|
||||
result.current.find(c => c.id === 'list-person')!.execute()
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'sectionGroup', type: 'Person' })
|
||||
})
|
||||
|
||||
it('uses default types when vault has no typed notes', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries: [] })))
|
||||
expect(result.current.find(c => c.id === 'new-event')).toBeDefined()
|
||||
expect(result.current.find(c => c.id === 'new-person')).toBeDefined()
|
||||
expect(result.current.find(c => c.id === 'new-project')).toBeDefined()
|
||||
expect(result.current.find(c => c.id === 'new-note')).toBeDefined()
|
||||
})
|
||||
|
||||
it('excludes Type entries from vault type list', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/type/event.md', isA: 'Type', title: 'Event' }),
|
||||
makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' }),
|
||||
]
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
|
||||
expect(result.current.find(c => c.id === 'new-type')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('excludes trashed entries from type extraction', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/event/old.md', isA: 'Event', trashed: true }),
|
||||
]
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
|
||||
// Should fall back to defaults since the only Event entry is trashed
|
||||
expect(result.current.find(c => c.id === 'new-event')).toBeDefined()
|
||||
expect(result.current.find(c => c.id === 'new-project')).toBeDefined()
|
||||
})
|
||||
|
||||
it('deduplicates types from multiple entries', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/event/a.md', isA: 'Event' }),
|
||||
makeEntry({ path: '/vault/event/b.md', isA: 'Event' }),
|
||||
makeEntry({ path: '/vault/event/c.md', isA: 'Event' }),
|
||||
]
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
|
||||
const eventCmds = result.current.filter(c => c.id === 'new-event')
|
||||
expect(eventCmds).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pluralizeType', () => {
|
||||
it('handles regular plurals', () => {
|
||||
expect(pluralizeType('Event')).toBe('Events')
|
||||
expect(pluralizeType('Project')).toBe('Projects')
|
||||
expect(pluralizeType('Note')).toBe('Notes')
|
||||
expect(pluralizeType('Topic')).toBe('Topics')
|
||||
})
|
||||
|
||||
it('handles special plurals', () => {
|
||||
expect(pluralizeType('Person')).toBe('People')
|
||||
expect(pluralizeType('Responsibility')).toBe('Responsibilities')
|
||||
})
|
||||
|
||||
it('handles words ending in y', () => {
|
||||
expect(pluralizeType('Category')).toBe('Categories')
|
||||
})
|
||||
|
||||
it('handles words ending in s/x/ch/sh', () => {
|
||||
expect(pluralizeType('Process')).toBe('Processes')
|
||||
expect(pluralizeType('Box')).toBe('Boxes')
|
||||
})
|
||||
|
||||
it('does not double-pluralize words ending in vowel+y', () => {
|
||||
expect(pluralizeType('Key')).toBe('Keys')
|
||||
expect(pluralizeType('Day')).toBe('Days')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractVaultTypes', () => {
|
||||
it('extracts unique types from entries', () => {
|
||||
const entries = [
|
||||
makeEntry({ isA: 'Event' }),
|
||||
makeEntry({ isA: 'Person' }),
|
||||
makeEntry({ isA: 'Event' }),
|
||||
]
|
||||
const types = extractVaultTypes(entries)
|
||||
expect(types).toEqual(['Event', 'Person'])
|
||||
})
|
||||
|
||||
it('returns default types when no entries', () => {
|
||||
const types = extractVaultTypes([])
|
||||
expect(types).toContain('Event')
|
||||
expect(types).toContain('Person')
|
||||
expect(types).toContain('Project')
|
||||
expect(types).toContain('Note')
|
||||
expect(types).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('excludes Type entries', () => {
|
||||
const entries = [
|
||||
makeEntry({ isA: 'Type' }),
|
||||
makeEntry({ isA: 'Event' }),
|
||||
]
|
||||
const types = extractVaultTypes(entries)
|
||||
expect(types).toEqual(['Event'])
|
||||
expect(types).not.toContain('Type')
|
||||
})
|
||||
|
||||
it('excludes trashed entries', () => {
|
||||
const entries = [makeEntry({ isA: 'Event', trashed: true })]
|
||||
const types = extractVaultTypes(entries)
|
||||
// Falls back to defaults since the only typed entry is trashed
|
||||
expect(types).toContain('Event')
|
||||
expect(types).toContain('Person')
|
||||
expect(types).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('returns sorted types', () => {
|
||||
const entries = [
|
||||
makeEntry({ isA: 'Procedure' }),
|
||||
makeEntry({ isA: 'Event' }),
|
||||
makeEntry({ isA: 'Note' }),
|
||||
]
|
||||
expect(extractVaultTypes(entries)).toEqual(['Event', 'Note', 'Procedure'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupSortKey', () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ interface CommandRegistryConfig {
|
||||
|
||||
onQuickOpen: () => void
|
||||
onCreateNote: () => void
|
||||
onCreateNoteOfType: (type: string) => void
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
onTrashNote: (path: string) => void
|
||||
@@ -29,6 +30,10 @@ interface CommandRegistryConfig {
|
||||
onCommitPush: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
zoomLevel: number
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
onCloseTab: (path: string) => void
|
||||
onGoBack?: () => void
|
||||
@@ -37,18 +42,66 @@ interface CommandRegistryConfig {
|
||||
canGoForward?: boolean
|
||||
}
|
||||
|
||||
const PLURAL_OVERRIDES: Record<string, string> = {
|
||||
Person: 'People',
|
||||
Responsibility: 'Responsibilities',
|
||||
}
|
||||
|
||||
const DEFAULT_TYPES = ['Event', 'Person', 'Project', 'Note']
|
||||
|
||||
export function pluralizeType(type: string): string {
|
||||
if (PLURAL_OVERRIDES[type]) return PLURAL_OVERRIDES[type]
|
||||
if (type.endsWith('s') || type.endsWith('x') || type.endsWith('ch') || type.endsWith('sh')) return `${type}es`
|
||||
if (type.endsWith('y') && !/[aeiou]y$/i.test(type)) return `${type.slice(0, -1)}ies`
|
||||
return `${type}s`
|
||||
}
|
||||
|
||||
export function extractVaultTypes(entries: VaultEntry[]): string[] {
|
||||
const typeSet = new Set<string>()
|
||||
for (const e of entries) {
|
||||
if (e.isA && e.isA !== 'Type' && !e.trashed) typeSet.add(e.isA)
|
||||
}
|
||||
if (typeSet.size === 0) return DEFAULT_TYPES
|
||||
return Array.from(typeSet).sort()
|
||||
}
|
||||
|
||||
const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Settings']
|
||||
|
||||
export function groupSortKey(group: CommandGroup): number {
|
||||
return GROUP_ORDER.indexOf(group)
|
||||
}
|
||||
|
||||
export function buildTypeCommands(
|
||||
types: string[],
|
||||
onCreateNoteOfType: (type: string) => void,
|
||||
onSelect: (sel: SidebarSelection) => void,
|
||||
): CommandAction[] {
|
||||
return types.flatMap((type) => {
|
||||
const slug = type.toLowerCase().replace(/\s+/g, '-')
|
||||
const plural = pluralizeType(type)
|
||||
return [
|
||||
{
|
||||
id: `new-${slug}`, label: `New ${type}`, group: 'Note' as CommandGroup,
|
||||
keywords: ['new', 'create', type.toLowerCase()],
|
||||
enabled: true, execute: () => onCreateNoteOfType(type),
|
||||
},
|
||||
{
|
||||
id: `list-${slug}`, label: `List ${plural}`, group: 'Navigation' as CommandGroup,
|
||||
keywords: ['list', 'show', 'filter', type.toLowerCase(), plural.toLowerCase()],
|
||||
enabled: true, execute: () => onSelect({ kind: 'sectionGroup', type }),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] {
|
||||
const {
|
||||
activeTabPath, entries, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onSave, onOpenSettings,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onSelect, onCloseTab,
|
||||
onCommitPush, onSetViewMode, onToggleInspector,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
} = config
|
||||
|
||||
@@ -60,6 +113,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
)
|
||||
const isArchived = activeEntry?.archived ?? false
|
||||
|
||||
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
|
||||
|
||||
return useMemo(() => {
|
||||
const cmds: CommandAction[] = [
|
||||
// Navigation
|
||||
@@ -92,17 +147,26 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
|
||||
// Settings
|
||||
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
|
||||
|
||||
// Type-aware: "New [Type]" and "List [Type]"
|
||||
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
|
||||
]
|
||||
|
||||
return cmds
|
||||
}, [
|
||||
hasActiveNote, activeTabPath, isArchived, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onSave, onOpenSettings,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onSelect, onCloseTab,
|
||||
onCommitPush, onSetViewMode, onToggleInspector,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
vaultTypes,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ interface EditorSaveConfig {
|
||||
setTabs: (fn: SetStateAction<any[]>) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onAfterSave?: () => void
|
||||
/** Called after content is persisted — used to clear unsaved state. */
|
||||
onNotePersisted?: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,7 +23,7 @@ interface EditorSaveConfig {
|
||||
*/
|
||||
const noop = () => {}
|
||||
|
||||
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop }: EditorSaveConfig) {
|
||||
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop, onNotePersisted }: EditorSaveConfig) {
|
||||
const pendingContentRef = useRef<{ path: string; content: string } | null>(null)
|
||||
|
||||
const updateTabAndContent = useCallback((path: string, content: string) => {
|
||||
@@ -40,20 +42,29 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
|
||||
if (pathFilter && pending.path !== pathFilter) return false
|
||||
await saveNote(pending.path, pending.content)
|
||||
pendingContentRef.current = null
|
||||
onNotePersisted?.(pending.path)
|
||||
return true
|
||||
}, [saveNote])
|
||||
}, [saveNote, onNotePersisted])
|
||||
|
||||
/** Called by Cmd+S — persists the current editor content to disk */
|
||||
const handleSave = useCallback(async () => {
|
||||
/** Called by Cmd+S — persists the current editor content to disk.
|
||||
* Accepts optional fallback for unsaved notes with no pending edits. */
|
||||
const handleSave = useCallback(async (unsavedFallback?: { path: string; content: string }) => {
|
||||
try {
|
||||
const saved = await flushPending()
|
||||
if (!saved && unsavedFallback) {
|
||||
await saveNote(unsavedFallback.path, unsavedFallback.content)
|
||||
onNotePersisted?.(unsavedFallback.path)
|
||||
setToastMessage('Saved')
|
||||
onAfterSave()
|
||||
return
|
||||
}
|
||||
setToastMessage(saved ? 'Saved' : 'Nothing to save')
|
||||
onAfterSave()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
setToastMessage(`Save failed: ${err}`)
|
||||
}
|
||||
}, [flushPending, setToastMessage, onAfterSave])
|
||||
}, [flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted])
|
||||
|
||||
/** Called by Editor onChange — buffers the latest content without saving */
|
||||
const handleContentChange = useCallback((path: string, content: string) => {
|
||||
|
||||
158
src/hooks/useEditorTabSwap.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './useEditorTabSwap'
|
||||
|
||||
describe('extractEditorBody', () => {
|
||||
it('strips frontmatter and preserves H1 heading for new note content', () => {
|
||||
const content = '---\ntitle: Untitled note\ntype: Note\nstatus: Active\n---\n\n# Untitled note\n\n'
|
||||
expect(extractEditorBody(content)).toBe('# Untitled note\n\n')
|
||||
})
|
||||
|
||||
it('strips frontmatter and preserves H1 with body content', () => {
|
||||
const content = '---\ntitle: Test\n---\n# Test\n\nBody text here.'
|
||||
expect(extractEditorBody(content)).toBe('# Test\n\nBody text here.')
|
||||
})
|
||||
|
||||
it('preserves H1 and body content after frontmatter', () => {
|
||||
const content = '---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nFirst paragraph.\n\nSecond paragraph.'
|
||||
expect(extractEditorBody(content)).toBe('# My Note\n\nFirst paragraph.\n\nSecond paragraph.')
|
||||
})
|
||||
|
||||
it('handles content without frontmatter', () => {
|
||||
const content = '# Just a Heading\n\nSome body text.'
|
||||
expect(extractEditorBody(content)).toBe('# Just a Heading\n\nSome body text.')
|
||||
})
|
||||
|
||||
it('handles content without frontmatter or heading', () => {
|
||||
const content = 'Just plain text.'
|
||||
expect(extractEditorBody(content)).toBe('Just plain text.')
|
||||
})
|
||||
|
||||
it('handles completely empty content', () => {
|
||||
expect(extractEditorBody('')).toBe('')
|
||||
})
|
||||
|
||||
it('handles frontmatter-only content', () => {
|
||||
const content = '---\ntitle: Empty\n---\n'
|
||||
expect(extractEditorBody(content)).toBe('')
|
||||
})
|
||||
|
||||
it('preserves wikilinks in body', () => {
|
||||
const content = '---\ntitle: Test\n---\n\n# Test\n\nSee [[Other Note]] for details.'
|
||||
expect(extractEditorBody(content)).toBe('# Test\n\nSee [[Other Note]] for details.')
|
||||
})
|
||||
|
||||
it('preserves non-leading headings', () => {
|
||||
const content = '---\ntitle: Test\n---\n\nSome intro text.\n\n# A Heading\n\nMore text.'
|
||||
expect(extractEditorBody(content)).toBe('Some intro text.\n\n# A Heading\n\nMore text.')
|
||||
})
|
||||
|
||||
it('preserves H1 for buildNoteContent output', () => {
|
||||
const content = '---\ntitle: My Project\ntype: Project\nstatus: Active\n---\n\n# My Project\n\n'
|
||||
expect(extractEditorBody(content)).toBe('# My Project\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getH1TextFromBlocks', () => {
|
||||
it('returns text from H1 heading block', () => {
|
||||
const blocks = [{
|
||||
type: 'heading',
|
||||
props: { level: 1 },
|
||||
content: [{ type: 'text', text: 'My Title', styles: {} }],
|
||||
}]
|
||||
expect(getH1TextFromBlocks(blocks)).toBe('My Title')
|
||||
})
|
||||
|
||||
it('returns null for empty blocks', () => {
|
||||
expect(getH1TextFromBlocks([])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for non-heading first block', () => {
|
||||
const blocks = [{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Just text' }],
|
||||
}]
|
||||
expect(getH1TextFromBlocks(blocks)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for H2 heading', () => {
|
||||
const blocks = [{
|
||||
type: 'heading',
|
||||
props: { level: 2 },
|
||||
content: [{ type: 'text', text: 'Subtitle' }],
|
||||
}]
|
||||
expect(getH1TextFromBlocks(blocks)).toBeNull()
|
||||
})
|
||||
|
||||
it('concatenates multiple text spans', () => {
|
||||
const blocks = [{
|
||||
type: 'heading',
|
||||
props: { level: 1 },
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello ' },
|
||||
{ type: 'text', text: 'World' },
|
||||
],
|
||||
}]
|
||||
expect(getH1TextFromBlocks(blocks)).toBe('Hello World')
|
||||
})
|
||||
|
||||
it('returns null for empty H1 content', () => {
|
||||
const blocks = [{
|
||||
type: 'heading',
|
||||
props: { level: 1 },
|
||||
content: [],
|
||||
}]
|
||||
expect(getH1TextFromBlocks(blocks)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for whitespace-only H1', () => {
|
||||
const blocks = [{
|
||||
type: 'heading',
|
||||
props: { level: 1 },
|
||||
content: [{ type: 'text', text: ' ' }],
|
||||
}]
|
||||
expect(getH1TextFromBlocks(blocks)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when blocks is null/undefined', () => {
|
||||
expect(getH1TextFromBlocks(null as unknown as unknown[])).toBeNull()
|
||||
expect(getH1TextFromBlocks(undefined as unknown as unknown[])).toBeNull()
|
||||
})
|
||||
|
||||
it('filters non-text inline content', () => {
|
||||
const blocks = [{
|
||||
type: 'heading',
|
||||
props: { level: 1 },
|
||||
content: [
|
||||
{ type: 'text', text: 'Title' },
|
||||
{ type: 'wikilink', props: { target: 'linked' } },
|
||||
],
|
||||
}]
|
||||
expect(getH1TextFromBlocks(blocks)).toBe('Title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceTitleInFrontmatter', () => {
|
||||
it('replaces title value in frontmatter', () => {
|
||||
const fm = '---\ntitle: Old Title\ntype: Note\n---\n\n'
|
||||
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntitle: New Title\ntype: Note\n---\n\n')
|
||||
})
|
||||
|
||||
it('handles title with extra spaces after colon', () => {
|
||||
const fm = '---\ntitle: Old Title\n---\n'
|
||||
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntitle: New Title\n---\n')
|
||||
})
|
||||
|
||||
it('returns unchanged frontmatter when no title line exists', () => {
|
||||
const fm = '---\ntype: Note\n---\n'
|
||||
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntype: Note\n---\n')
|
||||
})
|
||||
|
||||
it('replaces only the title line, not other fields', () => {
|
||||
const fm = '---\ntitle: Old\ntype: Note\nstatus: Active\n---\n\n'
|
||||
expect(replaceTitleInFrontmatter(fm, 'Updated')).toBe('---\ntitle: Updated\ntype: Note\nstatus: Active\n---\n\n')
|
||||
})
|
||||
|
||||
it('handles empty string as frontmatter', () => {
|
||||
expect(replaceTitleInFrontmatter('', 'Title')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
@@ -13,6 +14,39 @@ interface UseEditorTabSwapOptions {
|
||||
activeTabPath: string | null
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
/** Called on every editor change with the H1 text (null if no H1 first block). */
|
||||
onH1Change?: (h1Text: string | null) => void
|
||||
/** When .current is false, handleEditorChange won't update frontmatter title from H1. */
|
||||
syncActiveRef?: React.MutableRefObject<boolean>
|
||||
}
|
||||
|
||||
/** Strip the YAML frontmatter from raw file content, returning the body
|
||||
* (including any H1 heading) that should appear in the editor. */
|
||||
export function extractEditorBody(rawFileContent: string): string {
|
||||
const [, rawBody] = splitFrontmatter(rawFileContent)
|
||||
return rawBody.trimStart()
|
||||
}
|
||||
|
||||
/** Extract H1 text from the editor's first block, or null if not an H1. */
|
||||
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
|
||||
if (!blocks?.length) return null
|
||||
const first = blocks[0] as {
|
||||
type?: string
|
||||
props?: { level?: number }
|
||||
content?: Array<{ type?: string; text?: string }>
|
||||
}
|
||||
if (first.type !== 'heading' || first.props?.level !== 1) return null
|
||||
if (!Array.isArray(first.content)) return null
|
||||
const text = first.content
|
||||
.filter(item => item.type === 'text')
|
||||
.map(item => item.text || '')
|
||||
.join('')
|
||||
return text.trim() || null
|
||||
}
|
||||
|
||||
/** Replace the title: line in YAML frontmatter with a new title value. */
|
||||
export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string): string {
|
||||
return frontmatter.replace(/^(title:\s*).+$/m, `$1${newTitle}`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,7 +60,7 @@ interface UseEditorTabSwapOptions {
|
||||
*
|
||||
* Returns `handleEditorChange`, the onChange callback for SingleEditorView.
|
||||
*/
|
||||
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange }: UseEditorTabSwapOptions) {
|
||||
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, onH1Change, syncActiveRef }: UseEditorTabSwapOptions) {
|
||||
// Cache parsed blocks per tab path for instant switching
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
|
||||
const tabCacheRef = useRef<Map<string, any[]>>(new Map())
|
||||
@@ -40,6 +74,8 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange
|
||||
// Keep refs to callbacks for the onChange handler
|
||||
const onContentChangeRef = useRef(onContentChange)
|
||||
onContentChangeRef.current = onContentChange
|
||||
const onH1ChangeRef = useRef(onH1Change)
|
||||
onH1ChangeRef.current = onH1Change
|
||||
const tabsRef = useRef(tabs)
|
||||
tabsRef.current = tabs
|
||||
|
||||
@@ -77,13 +113,20 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange
|
||||
const restored = restoreWikilinksInBlocks(blocks)
|
||||
const bodyMarkdown = editor.blocksToMarkdownLossy(restored as typeof blocks)
|
||||
|
||||
// Reconstruct the full file: preserve original frontmatter + title heading
|
||||
// Reconstruct full file: frontmatter + body (which now includes H1 if present)
|
||||
const [frontmatter] = splitFrontmatter(tab.content)
|
||||
const title = tab.entry.title
|
||||
const fullContent = `${frontmatter}# ${title}\n\n${bodyMarkdown}`
|
||||
const h1Text = getH1TextFromBlocks(blocks)
|
||||
|
||||
// Keep frontmatter title: in sync with H1 when sync is active
|
||||
const isSyncActive = syncActiveRef?.current !== false
|
||||
const fm = (isSyncActive && h1Text)
|
||||
? replaceTitleInFrontmatter(frontmatter, h1Text)
|
||||
: frontmatter
|
||||
const fullContent = `${fm}${bodyMarkdown}`
|
||||
|
||||
onContentChangeRef.current?.(path, fullContent)
|
||||
}, [editor])
|
||||
onH1ChangeRef.current?.(h1Text)
|
||||
}, [editor, syncActiveRef])
|
||||
|
||||
// Swap document content when active tab changes.
|
||||
// Uses queueMicrotask to defer BlockNote mutations outside React's commit phase,
|
||||
@@ -151,10 +194,32 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange
|
||||
return
|
||||
}
|
||||
|
||||
const [, rawBody] = splitFrontmatter(tab.content)
|
||||
const body = rawBody.replace(/^# [^\n]*\n?/, '').trimStart()
|
||||
const body = extractEditorBody(tab.content)
|
||||
const preprocessed = preProcessWikilinks(body)
|
||||
|
||||
// Fast path: empty body (e.g. newly created notes). Skip the
|
||||
// potentially-async markdown parser and set a single empty paragraph
|
||||
// so the editor is immediately interactive.
|
||||
if (!preprocessed.trim()) {
|
||||
const emptyDoc = [{ type: 'paragraph', content: [] }]
|
||||
cache.set(targetPath, emptyDoc)
|
||||
applyBlocks(emptyDoc)
|
||||
return
|
||||
}
|
||||
|
||||
// Fast path: H1-only content (e.g. newly created notes that just have
|
||||
// the title heading). Build blocks directly to stay instant.
|
||||
const h1OnlyMatch = preprocessed.trim().match(/^# (.+)$/)
|
||||
if (h1OnlyMatch) {
|
||||
const h1Doc = [
|
||||
{ type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] },
|
||||
{ type: 'paragraph', content: [], children: [] },
|
||||
]
|
||||
cache.set(targetPath, h1Doc)
|
||||
applyBlocks(h1Doc)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = editor.tryParseMarkdownToBlocks(preprocessed)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
|
||||
|
||||
153
src/hooks/useHeadingTitleSync.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useHeadingTitleSync } from './useHeadingTitleSync'
|
||||
|
||||
describe('useHeadingTitleSync', () => {
|
||||
beforeEach(() => { vi.useFakeTimers() })
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
it('calls onTitleSync after debounce when H1 differs from title', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old Title', onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onH1Changed('New Title') })
|
||||
expect(onTitleSync).not.toHaveBeenCalled()
|
||||
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'New Title')
|
||||
})
|
||||
|
||||
it('does not call onTitleSync when H1 matches current title', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Same Title', onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onH1Changed('Same Title') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call onTitleSync when H1 is null', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onH1Changed(null) })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('debounces rapid H1 changes and uses last value', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old', onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onH1Changed('New 1') })
|
||||
act(() => { vi.advanceTimersByTime(200) })
|
||||
act(() => { result.current.onH1Changed('New 2') })
|
||||
act(() => { vi.advanceTimersByTime(200) })
|
||||
act(() => { result.current.onH1Changed('New 3') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
|
||||
expect(onTitleSync).toHaveBeenCalledTimes(1)
|
||||
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'New 3')
|
||||
})
|
||||
|
||||
it('does not call onTitleSync when no active tab', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: null, currentTitle: null, onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onH1Changed('Title') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets sync state when active tab changes', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result, rerender } = renderHook(
|
||||
({ path, title }) => useHeadingTitleSync({ activeTabPath: path, currentTitle: title, onTitleSync }),
|
||||
{ initialProps: { path: '/a.md', title: 'A' } }
|
||||
)
|
||||
|
||||
// Break sync on tab A
|
||||
act(() => { result.current.onManualRename('Custom', 'A H1') })
|
||||
|
||||
// H1 change should be ignored (sync broken)
|
||||
act(() => { result.current.onH1Changed('New A') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).not.toHaveBeenCalled()
|
||||
|
||||
// Switch to tab B — sync resets
|
||||
rerender({ path: '/b.md', title: 'B' })
|
||||
|
||||
// H1 change should now work
|
||||
act(() => { result.current.onH1Changed('New B') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).toHaveBeenCalledWith('/b.md', 'New B')
|
||||
})
|
||||
|
||||
it('breaks sync when manual rename differs from H1', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onManualRename('Custom Name', 'Title') })
|
||||
|
||||
// H1 changes should be ignored
|
||||
act(() => { result.current.onH1Changed('New H1') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not break sync when manual rename matches H1', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old', onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onManualRename('Same as H1', 'Same as H1') })
|
||||
|
||||
// Sync should still be active
|
||||
act(() => { result.current.onH1Changed('Updated') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'Updated')
|
||||
})
|
||||
|
||||
it('does not break sync when H1 is null during manual rename', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync })
|
||||
)
|
||||
|
||||
act(() => { result.current.onManualRename('New Name', null) })
|
||||
|
||||
// Sync should still be active (no H1 to compare)
|
||||
act(() => { result.current.onH1Changed('H1 Text') })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'H1 Text')
|
||||
})
|
||||
|
||||
it('clears pending debounce timer on tab switch', () => {
|
||||
const onTitleSync = vi.fn()
|
||||
const { result, rerender } = renderHook(
|
||||
({ path, title }) => useHeadingTitleSync({ activeTabPath: path, currentTitle: title, onTitleSync }),
|
||||
{ initialProps: { path: '/a.md', title: 'A' } }
|
||||
)
|
||||
|
||||
act(() => { result.current.onH1Changed('New A') })
|
||||
// Switch tab before debounce fires
|
||||
rerender({ path: '/b.md', title: 'B' })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
|
||||
// Should NOT fire for old tab
|
||||
expect(onTitleSync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
68
src/hooks/useHeadingTitleSync.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
interface HeadingTitleSyncConfig {
|
||||
activeTabPath: string | null
|
||||
currentTitle: string | null
|
||||
onTitleSync: (path: string, newTitle: string) => void
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 500
|
||||
|
||||
/**
|
||||
* Syncs the note title with the editor's first H1 heading.
|
||||
*
|
||||
* - On every editor change, the caller passes the current H1 text via onH1Changed.
|
||||
* - After a 500ms debounce, VaultEntry.title is updated to match.
|
||||
* - If the user manually renames the title (via tab) to something different from
|
||||
* the H1, sync breaks and stops updating until the tab is switched.
|
||||
* - If the H1 is deleted, the last synced title is kept (no clear).
|
||||
*/
|
||||
export function useHeadingTitleSync({
|
||||
activeTabPath,
|
||||
currentTitle,
|
||||
onTitleSync,
|
||||
}: HeadingTitleSyncConfig) {
|
||||
const syncActiveRef = useRef(true)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const activeTabPathRef = useRef(activeTabPath)
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
activeTabPathRef.current = activeTabPath
|
||||
const onTitleSyncRef = useRef(onTitleSync)
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
onTitleSyncRef.current = onTitleSync
|
||||
const currentTitleRef = useRef(currentTitle)
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
currentTitleRef.current = currentTitle
|
||||
|
||||
// Reset sync state when switching tabs
|
||||
useEffect(() => {
|
||||
syncActiveRef.current = true
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}, [activeTabPath])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => () => clearTimeout(debounceTimerRef.current), [])
|
||||
|
||||
/** Called on every editor change with the H1 text (null if no H1 first block). */
|
||||
const onH1Changed = useCallback((h1Text: string | null) => {
|
||||
if (!h1Text || !activeTabPathRef.current || !syncActiveRef.current) return
|
||||
if (h1Text === currentTitleRef.current) return
|
||||
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
if (syncActiveRef.current && activeTabPathRef.current) {
|
||||
onTitleSyncRef.current(activeTabPathRef.current, h1Text)
|
||||
}
|
||||
}, DEBOUNCE_MS)
|
||||
}, [])
|
||||
|
||||
/** Called when the user manually renames via tab double-click.
|
||||
* Breaks sync if the new title differs from the current H1. */
|
||||
const onManualRename = useCallback((newTitle: string, currentH1: string | null) => {
|
||||
if (currentH1 && currentH1 !== newTitle) {
|
||||
syncActiveRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { syncActiveRef, onH1Changed, onManualRename }
|
||||
}
|
||||
@@ -10,6 +10,9 @@ function makeHandlers(): MenuEventHandlers {
|
||||
onOpenSettings: vi.fn(),
|
||||
onToggleInspector: vi.fn(),
|
||||
onCommandPalette: vi.fn(),
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
onZoomReset: vi.fn(),
|
||||
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
|
||||
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
|
||||
activeTabPath: '/vault/test.md',
|
||||
@@ -84,6 +87,24 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onCommandPalette).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('view-zoom-in triggers zoom in', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('view-zoom-in', h)
|
||||
expect(h.onZoomIn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('view-zoom-out triggers zoom out', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('view-zoom-out', h)
|
||||
expect(h.onZoomOut).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('view-zoom-reset triggers zoom reset', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('view-zoom-reset', h)
|
||||
expect(h.onZoomReset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unknown event ID does nothing', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('unknown-event', h)
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface MenuEventHandlers {
|
||||
onOpenSettings: () => void
|
||||
onToggleInspector: () => void
|
||||
onCommandPalette: () => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
activeTabPath: string | null
|
||||
@@ -21,23 +24,31 @@ const VIEW_MODE_MAP: Record<string, ViewMode> = {
|
||||
'view-all': 'all',
|
||||
}
|
||||
|
||||
type SimpleHandler = 'onCreateNote' | 'onQuickOpen' | 'onSave' | 'onOpenSettings' | 'onToggleInspector' | 'onCommandPalette' | 'onZoomIn' | 'onZoomOut' | 'onZoomReset'
|
||||
|
||||
const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
|
||||
'file-new-note': 'onCreateNote',
|
||||
'file-quick-open': 'onQuickOpen',
|
||||
'file-save': 'onSave',
|
||||
'app-settings': 'onOpenSettings',
|
||||
'view-toggle-inspector': 'onToggleInspector',
|
||||
'view-command-palette': 'onCommandPalette',
|
||||
'view-zoom-in': 'onZoomIn',
|
||||
'view-zoom-out': 'onZoomOut',
|
||||
'view-zoom-reset': 'onZoomReset',
|
||||
}
|
||||
|
||||
/** Dispatch a Tauri menu event ID to the matching handler. Exported for testing. */
|
||||
export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
|
||||
const viewMode = VIEW_MODE_MAP[id]
|
||||
if (viewMode) { h.onSetViewMode(viewMode); return }
|
||||
|
||||
switch (id) {
|
||||
case 'file-new-note': h.onCreateNote(); break
|
||||
case 'file-quick-open': h.onQuickOpen(); break
|
||||
case 'file-save': h.onSave(); break
|
||||
case 'file-close-tab': {
|
||||
const path = h.activeTabPathRef.current
|
||||
if (path) h.handleCloseTabRef.current(path)
|
||||
break
|
||||
}
|
||||
case 'app-settings': h.onOpenSettings(); break
|
||||
case 'view-toggle-inspector': h.onToggleInspector(); break
|
||||
case 'view-command-palette': h.onCommandPalette(); break
|
||||
const simple = SIMPLE_EVENT_MAP[id]
|
||||
if (simple) { h[simple](); return }
|
||||
|
||||
if (id === 'file-close-tab') {
|
||||
const path = h.activeTabPathRef.current
|
||||
if (path) h.handleCloseTabRef.current(path)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,11 @@ export interface MultiSelectState {
|
||||
toggle: (path: string) => void
|
||||
selectRange: (toPath: string) => void
|
||||
clear: () => void
|
||||
setAnchor: (path: string) => void
|
||||
selectAll: () => void
|
||||
}
|
||||
|
||||
export function useMultiSelect(visibleEntries: VaultEntry[]): MultiSelectState {
|
||||
export function useMultiSelect(visibleEntries: VaultEntry[], activePath: string | null = null): MultiSelectState {
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set())
|
||||
const lastClickedRef = useRef<string | null>(null)
|
||||
|
||||
@@ -25,7 +26,7 @@ export function useMultiSelect(visibleEntries: VaultEntry[]): MultiSelectState {
|
||||
}, [])
|
||||
|
||||
const selectRange = useCallback((toPath: string) => {
|
||||
const fromPath = lastClickedRef.current
|
||||
const fromPath = lastClickedRef.current ?? activePath
|
||||
if (!fromPath) {
|
||||
toggle(toPath)
|
||||
return
|
||||
@@ -45,13 +46,17 @@ export function useMultiSelect(visibleEntries: VaultEntry[]): MultiSelectState {
|
||||
return next
|
||||
})
|
||||
lastClickedRef.current = toPath
|
||||
}, [visibleEntries, toggle])
|
||||
}, [visibleEntries, activePath, toggle])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setSelectedPaths(new Set())
|
||||
lastClickedRef.current = null
|
||||
}, [])
|
||||
|
||||
const setAnchor = useCallback((path: string) => {
|
||||
lastClickedRef.current = path
|
||||
}, [])
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedPaths(new Set(visibleEntries.map((e) => e.path)))
|
||||
}, [visibleEntries])
|
||||
@@ -62,6 +67,7 @@ export function useMultiSelect(visibleEntries: VaultEntry[]): MultiSelectState {
|
||||
toggle,
|
||||
selectRange,
|
||||
clear,
|
||||
setAnchor,
|
||||
selectAll,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,12 +518,12 @@ describe('useNoteActions hook', () => {
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate works with pending save callbacks', async () => {
|
||||
const addPendingSave = vi.fn()
|
||||
const removePendingSave = vi.fn()
|
||||
it('handleCreateNoteImmediate calls trackUnsaved and markContentPending (no disk write)', async () => {
|
||||
const trackUnsaved = vi.fn()
|
||||
const markContentPending = vi.fn()
|
||||
const config = makeConfig()
|
||||
config.addPendingSave = addPendingSave
|
||||
config.removePendingSave = removePendingSave
|
||||
config.trackUnsaved = trackUnsaved
|
||||
config.markContentPending = markContentPending
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
@@ -532,8 +532,8 @@ describe('useNoteActions hook', () => {
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
})
|
||||
|
||||
expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'))
|
||||
expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'))
|
||||
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'))
|
||||
expect(markContentPending).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'), expect.stringContaining('Untitled note'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -573,12 +573,7 @@ describe('useNoteActions hook', () => {
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles rapid creation with one failure independently', async () => {
|
||||
vi.mocked(invoke)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('disk full'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
|
||||
it('handleCreateNoteImmediate does not call invoke (no disk write)', async () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
await act(async () => {
|
||||
@@ -589,8 +584,34 @@ describe('useNoteActions hook', () => {
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(3)
|
||||
expect(removeEntry).toHaveBeenCalledTimes(1)
|
||||
expect(removeEntry).toHaveBeenCalledWith(expect.stringContaining('untitled-note-2.md'))
|
||||
// No disk writes for immediate creation — notes are unsaved/in-memory
|
||||
expect(invoke).not.toHaveBeenCalled()
|
||||
expect(removeEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closing unsaved tab removes entry', () => {
|
||||
const clearUnsaved = vi.fn()
|
||||
const unsavedPaths = new Set<string>()
|
||||
const config = makeConfig()
|
||||
config.clearUnsaved = clearUnsaved
|
||||
config.unsavedPaths = unsavedPaths
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate()
|
||||
})
|
||||
|
||||
const createdPath = addEntry.mock.calls[0][0].path
|
||||
unsavedPaths.add(createdPath) // simulate trackUnsaved
|
||||
config.unsavedPaths = unsavedPaths // update ref
|
||||
|
||||
act(() => {
|
||||
result.current.handleCloseTab(createdPath)
|
||||
})
|
||||
|
||||
expect(removeEntry).toHaveBeenCalledWith(createdPath)
|
||||
expect(clearUnsaved).toHaveBeenCalledWith(createdPath)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
@@ -28,6 +28,11 @@ export interface NoteActionsConfig {
|
||||
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
|
||||
addPendingSave?: (path: string) => void
|
||||
removePendingSave?: (path: string) => void
|
||||
trackUnsaved?: (path: string) => void
|
||||
clearUnsaved?: (path: string) => void
|
||||
unsavedPaths?: Set<string>
|
||||
/** Called when an unsaved note is created so the save system can buffer its initial content. */
|
||||
markContentPending?: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
async function performRename(
|
||||
@@ -263,10 +268,13 @@ async function runFrontmatterAndApply(
|
||||
export function useNoteActions(config: NoteActionsConfig) {
|
||||
const { addEntry, removeEntry, updateContent, entries, setToastMessage, updateEntry, addPendingSave, removePendingSave } = config
|
||||
const tabMgmt = useTabManagement()
|
||||
const { setTabs, handleSelectNote, openTabWithContent, handleCloseTab, activeTabPathRef, handleSwitchTab } = tabMgmt
|
||||
const { setTabs, handleSelectNote, openTabWithContent, handleCloseTab, handleCloseTabRef, activeTabPathRef, handleSwitchTab } = tabMgmt
|
||||
const tabsRef = useRef(tabMgmt.tabs)
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
tabsRef.current = tabMgmt.tabs
|
||||
const unsavedPathsRef = useRef(config.unsavedPaths)
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
unsavedPathsRef.current = config.unsavedPaths
|
||||
|
||||
const updateTabContent = useCallback((path: string, newContent: string) => {
|
||||
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))
|
||||
@@ -300,10 +308,26 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
|
||||
pendingNamesRef.current.add(title)
|
||||
handleCreateNote(title, noteType)
|
||||
const resolved = resolveNewNote(title, noteType)
|
||||
openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
config.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor()
|
||||
setTimeout(() => pendingNamesRef.current.delete(title), 500)
|
||||
}, [entries, handleCreateNote])
|
||||
}, [entries, openTabWithContent, addEntry, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
|
||||
/** Close tab and discard entry+unsaved state if the note was never persisted. */
|
||||
const handleCloseTabWithCleanup = useCallback((path: string) => {
|
||||
if (unsavedPathsRef.current?.has(path)) {
|
||||
removeEntry(path)
|
||||
config.clearUnsaved?.(path)
|
||||
}
|
||||
handleCloseTab(path)
|
||||
}, [handleCloseTab, removeEntry, config.clearUnsaved]) // eslint-disable-line react-hooks/exhaustive-deps -- ref access is stable
|
||||
|
||||
// Keep handleCloseTabRef in sync so Cmd+W and menu events also clean up unsaved notes.
|
||||
useEffect(() => { handleCloseTabRef.current = handleCloseTabWithCleanup })
|
||||
|
||||
const handleCreateType = useCallback((typeName: string) => {
|
||||
createAndPersist(resolveNewType(typeName), addEntry, openTabWithContent, persistCbs)
|
||||
@@ -340,6 +364,7 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
|
||||
return {
|
||||
...tabMgmt,
|
||||
handleCloseTab: handleCloseTabWithCleanup,
|
||||
handleNavigateWikilink,
|
||||
handleCreateNote,
|
||||
handleCreateNoteImmediate,
|
||||
|
||||
204
src/hooks/useOnboarding.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// localStorage mock
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { store = {} },
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
||||
|
||||
const mockInvokeFn = vi.fn()
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/vault-dialog', () => ({
|
||||
pickFolder: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useOnboarding } from './useOnboarding'
|
||||
import { pickFolder } from '../utils/vault-dialog'
|
||||
|
||||
describe('useOnboarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('transitions to ready when vault exists', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'check_vault_exists') return true
|
||||
return null
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/path'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('ready')
|
||||
})
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/vault/path' })
|
||||
})
|
||||
|
||||
it('shows welcome screen when vault does not exist', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Laputa' })
|
||||
})
|
||||
|
||||
it('shows vault-missing when previously dismissed and vault gone', async () => {
|
||||
localStorage.setItem('laputa_welcome_dismissed', '1')
|
||||
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/deleted'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('vault-missing')
|
||||
})
|
||||
expect(result.current.state).toEqual({
|
||||
status: 'vault-missing',
|
||||
vaultPath: '/vault/deleted',
|
||||
defaultPath: '/mock/Documents/Laputa',
|
||||
})
|
||||
})
|
||||
|
||||
it('handleCreateVault creates vault and transitions to ready', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'create_getting_started_vault') return '/mock/Documents/Laputa'
|
||||
return null
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateVault()
|
||||
})
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Laputa' })
|
||||
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
|
||||
})
|
||||
|
||||
it('handleCreateVault sets error on failure', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'create_getting_started_vault') throw 'Permission denied'
|
||||
return null
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateVault()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Permission denied')
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
it('handleOpenFolder opens folder picker and transitions to ready', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
vi.mocked(pickFolder).mockResolvedValue('/selected/folder')
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleOpenFolder()
|
||||
})
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/selected/folder' })
|
||||
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
|
||||
})
|
||||
|
||||
it('handleOpenFolder does nothing when picker is cancelled', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
vi.mocked(pickFolder).mockResolvedValue(null)
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleOpenFolder()
|
||||
})
|
||||
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
it('handleDismiss marks dismissed and transitions to ready', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleDismiss()
|
||||
})
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/vault/missing' })
|
||||
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
|
||||
})
|
||||
|
||||
it('falls back to ready if commands fail', async () => {
|
||||
mockInvokeFn.mockRejectedValue(new Error('command not found'))
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/path'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('ready')
|
||||
})
|
||||
})
|
||||
})
|
||||
98
src/hooks/useOnboarding.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { pickFolder } from '../utils/vault-dialog'
|
||||
|
||||
type OnboardingState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'welcome'; defaultPath: string }
|
||||
| { status: 'vault-missing'; vaultPath: string; defaultPath: string }
|
||||
| { status: 'ready'; vaultPath: string }
|
||||
|
||||
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
const DISMISSED_KEY = 'laputa_welcome_dismissed'
|
||||
|
||||
function wasDismissed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DISMISSED_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function markDismissed(): void {
|
||||
try {
|
||||
localStorage.setItem(DISMISSED_KEY, '1')
|
||||
} catch {
|
||||
// localStorage may be unavailable in some contexts
|
||||
}
|
||||
}
|
||||
|
||||
export function useOnboarding(initialVaultPath: string) {
|
||||
const [state, setState] = useState<OnboardingState>({ status: 'loading' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const defaultPath = await tauriCall<string>('get_default_vault_path', {})
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path: initialVaultPath })
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (exists) {
|
||||
setState({ status: 'ready', vaultPath: initialVaultPath })
|
||||
} else if (wasDismissed()) {
|
||||
// User previously dismissed — show vault-missing instead of welcome
|
||||
setState({ status: 'vault-missing', vaultPath: initialVaultPath, defaultPath })
|
||||
} else {
|
||||
setState({ status: 'welcome', defaultPath })
|
||||
}
|
||||
} catch {
|
||||
// If commands fail (e.g. mock mode), just proceed
|
||||
if (!cancelled) setState({ status: 'ready', vaultPath: initialVaultPath })
|
||||
}
|
||||
}
|
||||
|
||||
check()
|
||||
return () => { cancelled = true }
|
||||
}, [initialVaultPath])
|
||||
|
||||
const handleCreateVault = useCallback(async () => {
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath: null })
|
||||
markDismissed()
|
||||
setState({ status: 'ready', vaultPath })
|
||||
} catch (err) {
|
||||
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
try {
|
||||
const path = await pickFolder('Open vault folder')
|
||||
if (!path) return
|
||||
markDismissed()
|
||||
setState({ status: 'ready', vaultPath: path })
|
||||
} catch (err) {
|
||||
setError(`Failed to open folder: ${err}`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
markDismissed()
|
||||
setState({ status: 'ready', vaultPath: initialVaultPath })
|
||||
}, [initialVaultPath])
|
||||
|
||||
return { state, creating, error, handleCreateVault, handleOpenFolder, handleDismiss }
|
||||
}
|
||||
@@ -233,6 +233,47 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.getNoteStatus('/vault/note/new.md')).toBe('new')
|
||||
})
|
||||
|
||||
it('returns unsaved for paths in unsavedPaths', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
|
||||
|
||||
act(() => {
|
||||
result.current.addEntry(newEntry, '# Draft')
|
||||
result.current.trackUnsaved('/vault/note/draft.md')
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved')
|
||||
})
|
||||
|
||||
it('unsaved has higher priority than new', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
|
||||
|
||||
act(() => {
|
||||
result.current.addEntry(newEntry, '# Draft')
|
||||
result.current.trackUnsaved('/vault/note/draft.md')
|
||||
})
|
||||
|
||||
// addEntry also calls trackNew, so path is in both newPaths and unsavedPaths
|
||||
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved')
|
||||
})
|
||||
|
||||
it('clearUnsaved transitions from unsaved to new', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
|
||||
|
||||
act(() => {
|
||||
result.current.addEntry(newEntry, '# Draft')
|
||||
result.current.trackUnsaved('/vault/note/draft.md')
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved')
|
||||
|
||||
act(() => { result.current.clearUnsaved('/vault/note/draft.md') })
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('new')
|
||||
})
|
||||
|
||||
it('treats untracked files as new (green dot, not orange)', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
@@ -419,4 +460,16 @@ describe('resolveNoteStatus', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], emptyPending)).toBe('modified')
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(), [], emptyPending)).toBe('clean')
|
||||
})
|
||||
|
||||
it('unsaved takes priority over all other statuses', () => {
|
||||
const unsaved = new Set(['/vault/x.md'])
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], undefined, unsaved)).toBe('unsaved')
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], undefined, unsaved)).toBe('unsaved')
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], new Set(['/vault/x.md']), unsaved)).toBe('unsaved')
|
||||
})
|
||||
|
||||
it('without unsavedPaths parameter, behavior is unchanged', () => {
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new')
|
||||
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,26 @@ function useNewNoteTracker() {
|
||||
return { newPaths, trackNew, clear }
|
||||
}
|
||||
|
||||
function useUnsavedTracker() {
|
||||
const [unsavedPaths, setUnsavedPaths] = useState<Set<string>>(new Set())
|
||||
|
||||
const trackUnsaved = useCallback((path: string) => {
|
||||
setUnsavedPaths((prev) => new Set(prev).add(path))
|
||||
}, [])
|
||||
|
||||
const clearUnsaved = useCallback((path: string) => {
|
||||
setUnsavedPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(path)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearAll = useCallback(() => setUnsavedPaths(new Set()), [])
|
||||
|
||||
return { unsavedPaths, trackUnsaved, clearUnsaved, clearAll }
|
||||
}
|
||||
|
||||
function usePendingSaveTracker() {
|
||||
const [pendingSavePaths, setPendingSavePaths] = useState<Set<string>>(new Set())
|
||||
|
||||
@@ -61,8 +81,9 @@ function usePendingSaveTracker() {
|
||||
}
|
||||
|
||||
export function resolveNoteStatus(
|
||||
path: string, newPaths: Set<string>, modifiedFiles: ModifiedFile[], pendingSavePaths?: Set<string>,
|
||||
path: string, newPaths: Set<string>, modifiedFiles: ModifiedFile[], pendingSavePaths?: Set<string>, unsavedPaths?: Set<string>,
|
||||
): NoteStatus {
|
||||
if (unsavedPaths?.has(path)) return 'unsaved'
|
||||
if (pendingSavePaths?.has(path)) return 'pendingSave'
|
||||
if (newPaths.has(path)) return 'new'
|
||||
const gitEntry = modifiedFiles.find((f) => f.path === path)
|
||||
@@ -78,10 +99,11 @@ export function useVaultLoader(vaultPath: string) {
|
||||
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
|
||||
const tracker = useNewNoteTracker()
|
||||
const pendingSave = usePendingSaveTracker()
|
||||
const unsaved = useUnsavedTracker()
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
|
||||
setEntries([]); setAllContent({}); setModifiedFiles([]); tracker.clear()
|
||||
setEntries([]); setAllContent({}); setModifiedFiles([]); tracker.clear(); unsaved.clearAll()
|
||||
loadVaultData(vaultPath)
|
||||
.then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) })
|
||||
.catch((err) => console.warn('Vault scan failed:', err))
|
||||
@@ -139,7 +161,7 @@ export function useVaultLoader(vaultPath: string) {
|
||||
tauriCall<string>('get_file_diff', { vaultPath, path }, { path }), [vaultPath])
|
||||
|
||||
const getNoteStatus = useCallback((path: string): NoteStatus =>
|
||||
resolveNoteStatus(path, tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths), [tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths])
|
||||
resolveNoteStatus(path, tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths), [tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths])
|
||||
|
||||
const commitAndPush = useCallback((message: string): Promise<string> =>
|
||||
commitWithPush(vaultPath, message), [vaultPath])
|
||||
@@ -158,5 +180,8 @@ export function useVaultLoader(vaultPath: string) {
|
||||
getNoteStatus, commitAndPush, reloadVault,
|
||||
addPendingSave: pendingSave.addPendingSave,
|
||||
removePendingSave: pendingSave.removePendingSave,
|
||||
unsavedPaths: unsaved.unsavedPaths,
|
||||
trackUnsaved: unsaved.trackUnsaved,
|
||||
clearUnsaved: unsaved.clearUnsaved,
|
||||
}
|
||||
}
|
||||
|
||||
113
src/hooks/useZoom.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useZoom } from './useZoom'
|
||||
|
||||
// Mock localStorage (jsdom's may be incomplete)
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
|
||||
removeItem: vi.fn((key: string) => { delete store[key] }),
|
||||
clear: vi.fn(() => { store = {} }),
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
||||
|
||||
describe('useZoom', () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
document.documentElement.style.removeProperty('zoom')
|
||||
})
|
||||
|
||||
it('initializes at 100% by default', () => {
|
||||
const { result } = renderHook(() => useZoom())
|
||||
expect(result.current.zoomLevel).toBe(100)
|
||||
})
|
||||
|
||||
it('restores persisted zoom level from localStorage', () => {
|
||||
localStorageMock.setItem('laputa:zoom-level', '120')
|
||||
const { result } = renderHook(() => useZoom())
|
||||
expect(result.current.zoomLevel).toBe(120)
|
||||
})
|
||||
|
||||
it('ignores invalid persisted values', () => {
|
||||
localStorageMock.setItem('laputa:zoom-level', 'banana')
|
||||
const { result } = renderHook(() => useZoom())
|
||||
expect(result.current.zoomLevel).toBe(100)
|
||||
})
|
||||
|
||||
it('ignores out-of-range persisted values', () => {
|
||||
localStorageMock.setItem('laputa:zoom-level', '200')
|
||||
const { result } = renderHook(() => useZoom())
|
||||
expect(result.current.zoomLevel).toBe(100)
|
||||
})
|
||||
|
||||
it('zoomIn increases level by 10', () => {
|
||||
const { result } = renderHook(() => useZoom())
|
||||
act(() => result.current.zoomIn())
|
||||
expect(result.current.zoomLevel).toBe(110)
|
||||
expect(localStorageMock.getItem('laputa:zoom-level')).toBe('110')
|
||||
})
|
||||
|
||||
it('zoomOut decreases level by 10', () => {
|
||||
const { result } = renderHook(() => useZoom())
|
||||
act(() => result.current.zoomOut())
|
||||
expect(result.current.zoomLevel).toBe(90)
|
||||
expect(localStorageMock.getItem('laputa:zoom-level')).toBe('90')
|
||||
})
|
||||
|
||||
it('zoomIn clamps at 150', () => {
|
||||
localStorageMock.setItem('laputa:zoom-level', '150')
|
||||
const { result } = renderHook(() => useZoom())
|
||||
act(() => result.current.zoomIn())
|
||||
expect(result.current.zoomLevel).toBe(150)
|
||||
})
|
||||
|
||||
it('zoomOut clamps at 80', () => {
|
||||
localStorageMock.setItem('laputa:zoom-level', '80')
|
||||
const { result } = renderHook(() => useZoom())
|
||||
act(() => result.current.zoomOut())
|
||||
expect(result.current.zoomLevel).toBe(80)
|
||||
})
|
||||
|
||||
it('zoomReset returns to 100', () => {
|
||||
localStorageMock.setItem('laputa:zoom-level', '130')
|
||||
const { result } = renderHook(() => useZoom())
|
||||
act(() => result.current.zoomReset())
|
||||
expect(result.current.zoomLevel).toBe(100)
|
||||
expect(localStorageMock.getItem('laputa:zoom-level')).toBe('100')
|
||||
})
|
||||
|
||||
it('applies CSS zoom property to document element', () => {
|
||||
const spy = vi.spyOn(document.documentElement.style, 'setProperty')
|
||||
const { result } = renderHook(() => useZoom())
|
||||
spy.mockClear() // clear the mount call
|
||||
act(() => result.current.zoomIn())
|
||||
expect(spy).toHaveBeenCalledWith('zoom', '110%')
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('zoomIn and zoomOut are stable callbacks', () => {
|
||||
const { result, rerender } = renderHook(() => useZoom())
|
||||
const { zoomIn: a, zoomOut: b, zoomReset: c } = result.current
|
||||
rerender()
|
||||
expect(result.current.zoomIn).toBe(a)
|
||||
expect(result.current.zoomOut).toBe(b)
|
||||
expect(result.current.zoomReset).toBe(c)
|
||||
})
|
||||
|
||||
it('successive zoomIn calls accumulate', () => {
|
||||
const { result } = renderHook(() => useZoom())
|
||||
act(() => result.current.zoomIn())
|
||||
act(() => result.current.zoomIn())
|
||||
act(() => result.current.zoomIn())
|
||||
expect(result.current.zoomLevel).toBe(130)
|
||||
})
|
||||
|
||||
it('handles localStorage getItem throwing', () => {
|
||||
localStorageMock.getItem.mockImplementationOnce(() => { throw new Error('no storage') })
|
||||
const { result } = renderHook(() => useZoom())
|
||||
expect(result.current.zoomLevel).toBe(100)
|
||||
})
|
||||
})
|
||||
61
src/hooks/useZoom.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
const ZOOM_KEY = 'laputa:zoom-level'
|
||||
const MIN_ZOOM = 80
|
||||
const MAX_ZOOM = 150
|
||||
const STEP = 10
|
||||
const DEFAULT_ZOOM = 100
|
||||
|
||||
function loadPersistedZoom(): number {
|
||||
try {
|
||||
const stored = localStorage.getItem(ZOOM_KEY)
|
||||
if (stored !== null) {
|
||||
const val = Number(stored)
|
||||
if (val >= MIN_ZOOM && val <= MAX_ZOOM && val % STEP === 0) return val
|
||||
}
|
||||
} catch { /* localStorage unavailable */ }
|
||||
return DEFAULT_ZOOM
|
||||
}
|
||||
|
||||
function applyZoomToDocument(level: number): void {
|
||||
document.documentElement.style.setProperty('zoom', `${level}%`)
|
||||
}
|
||||
|
||||
function persistZoom(level: number): void {
|
||||
try { localStorage.setItem(ZOOM_KEY, String(level)) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function useZoom() {
|
||||
const [zoomLevel, setZoomLevel] = useState(loadPersistedZoom)
|
||||
|
||||
// Apply persisted zoom on mount
|
||||
useEffect(() => {
|
||||
applyZoomToDocument(zoomLevel)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- only on mount
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
setZoomLevel(prev => {
|
||||
const next = Math.min(MAX_ZOOM, prev + STEP)
|
||||
applyZoomToDocument(next)
|
||||
persistZoom(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setZoomLevel(prev => {
|
||||
const next = Math.max(MIN_ZOOM, prev - STEP)
|
||||
applyZoomToDocument(next)
|
||||
persistZoom(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const zoomReset = useCallback(() => {
|
||||
setZoomLevel(DEFAULT_ZOOM)
|
||||
applyZoomToDocument(DEFAULT_ZOOM)
|
||||
persistZoom(DEFAULT_ZOOM)
|
||||
}, [])
|
||||
|
||||
return { zoomLevel, zoomIn, zoomOut, zoomReset }
|
||||
}
|
||||
@@ -164,3 +164,8 @@
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.referenced-by-panel button:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ let mockSettings: Settings = {
|
||||
anthropic_key: null,
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: 'gho_mock_token_for_testing',
|
||||
github_username: 'lucaong',
|
||||
github_token: null,
|
||||
github_username: null,
|
||||
auto_pull_interval_minutes: 5,
|
||||
}
|
||||
|
||||
@@ -228,6 +228,12 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
}))
|
||||
return { results: matches, elapsed_ms: 42, query: q, mode: args.mode }
|
||||
},
|
||||
get_default_vault_path: () => '/Users/mock/Documents/Laputa',
|
||||
check_vault_exists: (args: { path: string }) => {
|
||||
// In mock mode, the demo-vault-v2 path always "exists"
|
||||
return args.path.includes('demo-vault-v2')
|
||||
},
|
||||
create_getting_started_vault: () => '/Users/mock/Documents/Laputa',
|
||||
}
|
||||
|
||||
export function addMockEntry(_entry: VaultEntry, content: string): void {
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface VaultEntry {
|
||||
outgoingLinks: string[]
|
||||
}
|
||||
|
||||
export type NoteStatus = 'new' | 'modified' | 'clean' | 'pendingSave'
|
||||
export type NoteStatus = 'new' | 'modified' | 'clean' | 'pendingSave' | 'unsaved'
|
||||
|
||||
export interface GitCommit {
|
||||
hash: string
|
||||
|
||||
@@ -65,8 +65,16 @@ describe('detectPropertyType', () => {
|
||||
expect(detectPropertyType('anything', undefined as never)).toBe('text')
|
||||
})
|
||||
|
||||
it('returns text for arrays', () => {
|
||||
expect(detectPropertyType('tags', ['a', 'b'])).toBe('text')
|
||||
it('detects tags from tag-like key names with array values', () => {
|
||||
expect(detectPropertyType('tags', ['a', 'b'])).toBe('tags')
|
||||
expect(detectPropertyType('keywords', ['react', 'tauri'])).toBe('tags')
|
||||
expect(detectPropertyType('categories', ['frontend'])).toBe('tags')
|
||||
expect(detectPropertyType('labels', ['bug', 'fix'])).toBe('tags')
|
||||
})
|
||||
|
||||
it('returns text for arrays with non-tag key names', () => {
|
||||
expect(detectPropertyType('aliases', ['a', 'b'])).toBe('text')
|
||||
expect(detectPropertyType('custom_list', ['x', 'y'])).toBe('text')
|
||||
})
|
||||
|
||||
it('treats date-keyed fields with non-date values as text', () => {
|
||||
@@ -152,4 +160,8 @@ describe('getEffectiveDisplayMode', () => {
|
||||
it('prefers override over auto-detection', () => {
|
||||
expect(getEffectiveDisplayMode('deadline', '2026-03-31', { deadline: 'text' })).toBe('text')
|
||||
})
|
||||
|
||||
it('uses tags override for array values', () => {
|
||||
expect(getEffectiveDisplayMode('custom', ['a', 'b'], { custom: 'tags' })).toBe('tags')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
|
||||
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url'
|
||||
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags'
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
|
||||
const COMMON_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/
|
||||
@@ -13,6 +13,7 @@ const STATUS_VALUES = new Set([
|
||||
|
||||
const STATUS_KEY_PATTERNS = ['status']
|
||||
const DATE_KEY_PATTERNS = ['date', 'deadline', 'due', 'start', 'end', 'scheduled']
|
||||
const TAGS_KEY_PATTERNS = ['tags', 'keywords', 'categories', 'labels']
|
||||
|
||||
function keyMatchesPatterns(key: string, patterns: string[]): boolean {
|
||||
const lower = key.toLowerCase()
|
||||
@@ -23,19 +24,18 @@ function isDateString(value: string): boolean {
|
||||
return ISO_DATE_RE.test(value) || COMMON_DATE_RE.test(value)
|
||||
}
|
||||
|
||||
function detectStringType(key: string, strValue: string): PropertyDisplayMode {
|
||||
if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status'
|
||||
if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status'
|
||||
if (isDateString(strValue)) return 'date'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
export function detectPropertyType(key: string, value: FrontmatterValue): PropertyDisplayMode {
|
||||
if (value === null || value === undefined) return 'text'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (Array.isArray(value)) return 'text'
|
||||
|
||||
const strValue = String(value)
|
||||
|
||||
if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status'
|
||||
if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status'
|
||||
if (keyMatchesPatterns(key, DATE_KEY_PATTERNS) && isDateString(strValue)) return 'date'
|
||||
if (isDateString(strValue)) return 'date'
|
||||
|
||||
return 'text'
|
||||
if (Array.isArray(value)) return keyMatchesPatterns(key, TAGS_KEY_PATTERNS) ? 'tags' : 'text'
|
||||
return detectStringType(key, String(value))
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'laputa:display-mode-overrides'
|
||||
|
||||
83
src/utils/tagStyles.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
getTagStyle,
|
||||
getTagColorKey,
|
||||
setTagColor,
|
||||
DEFAULT_TAG_STYLE,
|
||||
} from './tagStyles'
|
||||
|
||||
// Mock localStorage (jsdom's may be incomplete)
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { store = {} },
|
||||
get length() { return Object.keys(store).length },
|
||||
key: (i: number) => Object.keys(store)[i] ?? null,
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
||||
|
||||
const STORAGE_KEY = 'laputa:tag-color-overrides'
|
||||
|
||||
describe('tagStyles — color overrides', () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
// Reset module-level cache by clearing known overrides
|
||||
// We can't easily list all, but clearing known test keys suffices
|
||||
for (const tag of ['React', 'TypeScript', 'Tauri', 'CustomTag']) {
|
||||
setTagColor(tag, null)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns default style when no override exists', () => {
|
||||
expect(getTagStyle('SomeTag')).toEqual(DEFAULT_TAG_STYLE)
|
||||
})
|
||||
|
||||
it('getTagColorKey returns null when no override set', () => {
|
||||
expect(getTagColorKey('React')).toBeNull()
|
||||
})
|
||||
|
||||
it('setTagColor persists a color override', () => {
|
||||
setTagColor('React', 'blue')
|
||||
expect(getTagColorKey('React')).toBe('blue')
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toContain('"React":"blue"')
|
||||
})
|
||||
|
||||
it('getTagStyle uses override when set', () => {
|
||||
setTagColor('React', 'green')
|
||||
const style = getTagStyle('React')
|
||||
expect(style.color).toBe('var(--accent-green)')
|
||||
expect(style.bg).toBe('var(--accent-green-light)')
|
||||
})
|
||||
|
||||
it('setTagColor with null removes the override', () => {
|
||||
setTagColor('React', 'red')
|
||||
expect(getTagColorKey('React')).toBe('red')
|
||||
setTagColor('React', null)
|
||||
expect(getTagColorKey('React')).toBeNull()
|
||||
expect(getTagStyle('React')).toEqual(DEFAULT_TAG_STYLE)
|
||||
})
|
||||
|
||||
it('applies different overrides for different tags', () => {
|
||||
setTagColor('React', 'blue')
|
||||
setTagColor('TypeScript', 'purple')
|
||||
expect(getTagStyle('React').color).toBe('var(--accent-blue)')
|
||||
expect(getTagStyle('TypeScript').color).toBe('var(--accent-purple)')
|
||||
})
|
||||
|
||||
it('ignores invalid color key in override', () => {
|
||||
setTagColor('React', 'nonexistent-color')
|
||||
// Falls back to default since "nonexistent-color" isn't a valid ACCENT_COLOR key
|
||||
expect(getTagStyle('React')).toEqual(DEFAULT_TAG_STYLE)
|
||||
})
|
||||
|
||||
it('persists multiple overrides to localStorage', () => {
|
||||
setTagColor('React', 'blue')
|
||||
setTagColor('Tauri', 'orange')
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
|
||||
expect(stored).toEqual({ React: 'blue', Tauri: 'orange' })
|
||||
})
|
||||
})
|
||||
52
src/utils/tagStyles.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ACCENT_COLORS } from './typeColors'
|
||||
|
||||
export interface TagStyle {
|
||||
bg: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export const DEFAULT_TAG_STYLE: TagStyle = {
|
||||
bg: 'var(--accent-blue-light)',
|
||||
color: 'var(--accent-blue)',
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'laputa:tag-color-overrides'
|
||||
|
||||
const COLOR_KEY_TO_STYLE: Record<string, TagStyle> = Object.fromEntries(
|
||||
ACCENT_COLORS.map(c => [c.key, { bg: c.cssLight, color: c.css }]),
|
||||
)
|
||||
|
||||
const colorOverrides: Record<string, string> = loadColorOverrides()
|
||||
|
||||
function loadColorOverrides(): Record<string, string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as Record<string, string>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function setTagColor(tag: string, colorKey: string | null): void {
|
||||
if (colorKey === null) {
|
||||
delete colorOverrides[tag]
|
||||
} else {
|
||||
colorOverrides[tag] = colorKey
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(colorOverrides))
|
||||
} catch { /* storage full — silently ignore */ }
|
||||
}
|
||||
|
||||
export function getTagColorKey(tag: string): string | null {
|
||||
return colorOverrides[tag] ?? null
|
||||
}
|
||||
|
||||
export function getTagStyle(tag: string): TagStyle {
|
||||
const overrideKey = colorOverrides[tag]
|
||||
if (overrideKey) {
|
||||
const style = COLOR_KEY_TO_STYLE[overrideKey]
|
||||
if (style) return style
|
||||
}
|
||||
return DEFAULT_TAG_STYLE
|
||||
}
|
||||