Compare commits

...

18 Commits

Author SHA1 Message Date
Test
d962cd9eae fix: update Tauri signing pubkey (new keypair, password stored securely) 2026-02-27 21:53:47 +01:00
Test
88a8035c45 fix: update Tauri signing pubkey to match new keypair (empty password) 2026-02-27 21:42:14 +01:00
Test
035bd7f630 fix: update Tauri signing pubkey (regenerated keypair with empty password) 2026-02-27 21:26:19 +01:00
Test
9ce890576b fix: use TAURI_KEY_PASSWORD secret for signing (was hardcoded empty string) 2026-02-27 21:14:42 +01:00
Test
f569750666 chore: add .claude-done for pencil-ui-design-light-mode verification
Verified all 115 frames in ui-design.pen render in light mode.
No remaining before-variants, no duplicates found.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:06:43 +01:00
Test
9cfa8a7ca2 design: add theme Light to 25 dark-mode frames, remove before variants 2026-02-27 19:06:38 +01:00
Test
918b204d3d design: fix search panel overlay layout positioning 2026-02-27 19:05:16 +01:00
Luca Rossi
177e593e90 fix: use portal-based positioning for property panel dropdowns (#130)
* fix: use portal-based positioning for property panel dropdowns

StatusDropdown and DisplayModeSelector used absolute positioning
within the Inspector's overflow-hidden container, causing dropdowns
to be clipped when the Properties panel is narrow (200-280px).

Both now render via createPortal into document.body with fixed
positioning calculated from the trigger element's bounding rect.
This matches the pattern used by existing Radix UI components
(Select, Popover) in the codebase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* design: property dropdown narrow panel fix — before/after frames

---------

Co-authored-by: Laputa App <laputa@app.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:00:30 +01:00
Laputa App
959e4975e3 design: update new-note-creation.pen for unsaved state
Update tab indicator to blue dot (was green), add italic font
style to unsaved tab title, rename frame to match new behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:23:28 +01:00
Laputa App
a2f1476b98 feat: in-memory unsaved state for new notes (Cmd+N)
New notes created via Cmd+N now stay in memory until the user
explicitly saves with Cmd+S. This eliminates the disk-write
blocking delay and lets the cursor appear instantly.

- Add 'unsaved' to NoteStatus; blue dot + italic title in tab bar
- useUnsavedTracker in useVaultLoader manages unsaved path set
- useNoteActions skips persist on create, cleans up on close
- useEditorSave accepts unsavedFallback for first-save scenario
- App.tsx wires tracking via contentChangeRef + clearUnsaved

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:23:28 +01:00
Laputa App
9d5c90f5c0 feat: replace search result snippet with metadata subtitle
Replace the first-line-of-content snippet in search results with
a metadata row showing: relative date, created date (when different
from modified), word count, and outgoing link count.

Uses the existing formatSearchSubtitle() helper. Entry lookup now
stores full VaultEntry for metadata access. Graceful degradation
when fields are missing/zero.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:06:49 +01:00
Laputa App
e91a7ae7f6 design: search results subtitle with metadata frames
Two frames showing the redesigned search result subtitle:
- Main view with metadata (date, word count, links) replacing snippet
- States catalog: all metadata, same dates, no links, empty, no date

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:03:41 +01:00
Laputa App
47e38ba6b0 chore: add src-tauri/target to .gitignore 2026-02-27 17:32:07 +01:00
Laputa App
cbb88b34b8 fix: remove accidental src-tauri/target symlink from repo
Was accidentally committed as a circular symlink in previous commit.
Cargo recreates this directory automatically on build.
2026-02-27 17:31:56 +01:00
Laputa App
52b1693f43 fix: property dropdowns adapt to narrow panel width
- Popover and Select components: add collisionPadding=8 as default
- TypeSelector and AddPropertyForm SelectContent: position=popper, side=left
- DateValue and AddDateInput PopoverContent: side=left
Dropdowns now flip direction when hitting viewport edge instead of clipping.
2026-02-27 16:58:10 +01:00
Luca Rossi
3d784ce740 fix: unify status dropdown and make color swatch visible (#128)
Bug 1: Replace plain-text Radix Select in AddStatusInput with the same
StatusDropdown component used for existing status properties. Both now
show colored pills, search, and custom status creation.

Bug 2: Add color swatch dot next to each status option in the dropdown.
Clicking it opens an inline palette of 8 accent colors that persist via
localStorage (leveraging existing setStatusColor infrastructure).

Refactor StatusDropdown to extract useStatusFiltering, useStatusKeyboard
hooks and VaultSection/SuggestedSection/CreateSection sub-components to
keep Code Health above 9.2 threshold.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:53:31 +01:00
Luca Rossi
480d124a0e fix: multiselect range includes anchor note and prevents text selection (#127)
- selectRange() now includes the currently open note as the anchor
- Added setAnchor() to track which note is the selection start
- Added select-none to NoteList to prevent browser text selection on Shift+click

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:53:18 +01:00
lucaronin
b15a4d143c fix: raise property type dropdown z-index above BlockNote editor
BlockNote uses z-index: 11000 for its toolbar/popover elements.
The Radix UI Select and Popover portals used z-50 (z-index: 50),
causing all inspector dropdowns (type selector, property type picker,
date pickers) to render behind the editor. Raised to z-[12000] to
match the existing StatusDropdown and DisplayModeSelector z-index.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:37:56 +01:00
24 changed files with 1347 additions and 299 deletions

View File

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

View File

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

@@ -41,3 +41,4 @@ final_selection.py
# Claude Code task signals
.claude-done
.claude-blocked
src-tauri/target

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

View File

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

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

View File

@@ -50,7 +50,7 @@
"endpoints": [
"https://github.com/refactoringhq/laputa-app/releases/latest/download/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQxQjNDRUVGNTgxM0Q2RDQKUldUVTFoTlk3ODZ6MGNYSml6aWF4SzBvMTFvdDJFQWQwZHZ0d0lDeW9LekRHZ3h5MnJsb2lpenEK"
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRFQzlGQ0RFM0E1MTIzNDkKUldSSkkxRTYzdnpKVG13M0Zwd3M1RzErbWhJeEhBQUQyaG90bHBtMkNzMm1MNERZRlpXSGFRMTUK"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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