Compare commits
18 Commits
v0.1.0
...
v0.2026022
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d962cd9eae | ||
|
|
88a8035c45 | ||
|
|
035bd7f630 | ||
|
|
9ce890576b | ||
|
|
f569750666 | ||
|
|
9cfa8a7ca2 | ||
|
|
918b204d3d | ||
|
|
177e593e90 | ||
|
|
959e4975e3 | ||
|
|
a2f1476b98 | ||
|
|
9d5c90f5c0 | ||
|
|
e91a7ae7f6 | ||
|
|
47e38ba6b0 | ||
|
|
cbb88b34b8 | ||
|
|
52b1693f43 | ||
|
|
3d784ce740 | ||
|
|
480d124a0e | ||
|
|
b15a4d143c |
39
.claude-done
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)
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -83,7 +83,7 @@ jobs:
|
||||
- name: Build Tauri app (with signing)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ""
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
pnpm tauri build --target ${{ matrix.target }}
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -41,3 +41,4 @@ final_selection.py
|
||||
# Claude Code task signals
|
||||
.claude-done
|
||||
.claude-blocked
|
||||
src-tauri/target
|
||||
|
||||
56
design/fix-property-dropdown-narrow.pen
Normal file
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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
665
design/search-results-subtitle.pen
Normal file
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": {}
|
||||
}
|
||||
@@ -50,7 +50,7 @@
|
||||
"endpoints": [
|
||||
"https://github.com/refactoringhq/laputa-app/releases/latest/download/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQxQjNDRUVGNTgxM0Q2RDQKUldUVTFoTlk3ODZ6MGNYSml6aWF4SzBvMTFvdDJFQWQwZHZ0d0lDeW9LekRHZ3h5MnJsb2lpenEK"
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRFQzlGQ0RFM0E1MTIzNDkKUldSSkkxRTYzdnpKVG13M0Zwd3M1RzErbWhJeEhBQUQyaG90bHBtMkNzMm1MNERZRlpXSGFRMTUK"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/App.tsx
22
src/App.tsx
@@ -76,13 +76,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) => {
|
||||
@@ -127,7 +128,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 +203,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 })
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
detectPropertyType,
|
||||
} from '../utils/propertyTypes'
|
||||
import { StatusPill, StatusDropdown } from './StatusDropdown'
|
||||
import { SUGGESTED_STATUSES } from '../utils/statusStyles'
|
||||
|
||||
// Keys that are relationships (contain wikilinks)
|
||||
export const RELATIONSHIP_KEYS = new Set([
|
||||
@@ -149,7 +149,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}
|
||||
@@ -187,7 +187,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 +211,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,11 +221,12 @@ 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 => (
|
||||
@@ -232,7 +246,8 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -273,7 +288,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 +301,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-0 flex-1 items-center">
|
||||
<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"
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -357,7 +374,7 @@ function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent position="popper" side="left">
|
||||
{DISPLAY_MODE_OPTIONS.map(opt => {
|
||||
const OptIcon = DISPLAY_MODE_ICONS[opt.value]
|
||||
return (
|
||||
@@ -427,7 +444,7 @@ 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 => (
|
||||
|
||||
@@ -196,7 +196,7 @@ function countExpiredTrash(entries: VaultEntry[]): number {
|
||||
|
||||
// --- Click routing ---
|
||||
|
||||
type MultiSelectActions = { toggle: (path: string) => void; selectRange: (path: string) => void; clear: () => void }
|
||||
type MultiSelectActions = { toggle: (path: string) => void; selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void }
|
||||
|
||||
function routeNoteClick(
|
||||
entry: VaultEntry, e: React.MouseEvent,
|
||||
@@ -205,7 +205,7 @@ function routeNoteClick(
|
||||
) {
|
||||
if (e.shiftKey) { multiSelect.selectRange(entry.path) }
|
||||
else if (e.metaKey || e.ctrlKey) { multiSelect.toggle(entry.path) }
|
||||
else { multiSelect.clear(); onReplaceActiveTab(entry) }
|
||||
else { multiSelect.clear(); multiSelect.setAnchor(entry.path); onReplaceActiveTab(entry) }
|
||||
}
|
||||
|
||||
// --- Pure helpers extracted from NoteListInner to reduce cyclomatic complexity ---
|
||||
@@ -307,7 +307,7 @@ 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
|
||||
@@ -353,7 +353,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
), [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 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' }}>
|
||||
<h3 className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold">{resolveHeaderTitle(selection, typeDocument)}</h3>
|
||||
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { formatSearchSubtitle } from '../utils/noteListHelpers'
|
||||
|
||||
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,12 @@ 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 subtitle = entry ? formatSearchSubtitle(entry) : null
|
||||
return (
|
||||
<div
|
||||
key={result.path}
|
||||
@@ -212,9 +215,9 @@ function SearchContent({
|
||||
</Badge>
|
||||
)}
|
||||
</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 text-[11px] text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { getStatusStyle, SUGGESTED_STATUSES } from '../utils/statusStyles'
|
||||
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,157 @@ 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)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
const positionDropdown = useCallback((node: HTMLDivElement | null) => {
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
<div ref={anchorRef} data-testid="status-dropdown">
|
||||
{createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[12000]" onClick={onCancel} data-testid="status-dropdown-backdrop" />
|
||||
<div
|
||||
ref={positionDropdown}
|
||||
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
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user