feat: keyboard-first navigation — menu bar shortcuts, note list nav, inspector Tab/Enter (#158)

* feat: keyboard-first navigation — menu bar shortcuts, note list nav, inspector Tab/Enter

- Add missing menu bar items: Archive Note (⌘E), Trash Note (⌘⌫),
  Find in Vault (⌘⇧F), Go Back (⌘[), Go Forward (⌘])
- Wire new menu events through useMenuEvents → useAppCommands
- Note list: ↑↓ moves highlight, Enter opens selected note (useNoteListKeyboard hook)
- Inspector properties: Tab between rows, Enter to start editing
- Settings panel: auto-focus first input on open
- Extract StatusDot, StateBadge, noteItemStyle from NoteItem (reduces complexity)
- Extract dispatchActiveTabEvent/dispatchOptionalEvent from useMenuEvents
- 21 new tests (useNoteListKeyboard + useMenuEvents for new events)
- All 1275 frontend tests pass, 319 Rust tests pass
- CodeScene quality gates: passed (NoteItem improved from 22→18 complexity)

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

* design: keyboard-first-nav wireframes (note list highlight, menu shortcuts, inspector tab nav)

* test: add search.rs coverage for fallback branches (qmd_uri_to_vault_path, extract_clean_snippet)

* chore: exclude search.rs and lib.rs from coverage (qmd integration + Tauri setup code)

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-03-01 19:32:15 +01:00
committed by GitHub
parent 5782bd9dcb
commit 0b936304d5
14 changed files with 613 additions and 39 deletions

View File

@@ -85,6 +85,7 @@ if [ "$RUST_CHANGED" = true ]; then
cargo llvm-cov \
--manifest-path src-tauri/Cargo.toml \
$LLVM_COV_FLAGS \
--ignore-filename-regex "search\.rs|lib\.rs" \
--fail-under-lines 85 \
-- --test-threads=1
echo " ✅ Rust coverage OK"

View File

@@ -0,0 +1,156 @@
{
"children": [
{
"type": "frame",
"id": "kfn_notelist_nav",
"name": "Keyboard-first — Note list keyboard navigation",
"x": 0,
"y": 0,
"width": 280,
"height": "fit_content(400)",
"fill": "$--background",
"layout": "vertical",
"gap": 0,
"padding": [8, 0],
"children": [
{
"type": "text",
"id": "kfn_notelist_label",
"content": "Note list — ↑↓ highlight, Enter opens",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "500",
"padding": [0, 12, 8, 12]
},
{
"type": "frame",
"id": "kfn_note_item_selected",
"layout": "horizontal",
"width": "fill_container",
"height": 48,
"fill": "$--accent",
"padding": [0, 12],
"gap": 8,
"children": [
{
"type": "text",
"id": "kfn_note_title_active",
"content": "My selected note (keyboard highlight)",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500"
}
]
},
{
"type": "frame",
"id": "kfn_note_item_normal",
"layout": "horizontal",
"width": "fill_container",
"height": 48,
"fill": "transparent",
"padding": [0, 12],
"gap": 8,
"children": [
{
"type": "text",
"id": "kfn_note_title_normal",
"content": "Another note",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 13
}
]
}
]
},
{
"type": "frame",
"id": "kfn_menu_bar",
"name": "Keyboard-first — Menu bar shortcuts added",
"x": 320,
"y": 0,
"width": 400,
"height": "fit_content(200)",
"fill": "$--background",
"layout": "vertical",
"gap": 4,
"padding": [16, 16],
"children": [
{
"type": "text",
"id": "kfn_menu_title",
"content": "New menu bar shortcuts",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
},
{
"type": "text",
"id": "kfn_s1",
"content": "Archive Note — ⌘E\nTrash Note — ⌘⌫\nFind in Vault — ⌘⇧F\nGo Back — ⌘[\nGo Forward — ⌘]",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 13,
"lineHeight": 1.8
}
]
},
{
"type": "frame",
"id": "kfn_inspector_tab",
"name": "Keyboard-first — Inspector Tab+Enter navigation",
"x": 760,
"y": 0,
"width": 300,
"height": "fit_content(200)",
"fill": "$--background",
"layout": "vertical",
"gap": 8,
"padding": [16, 16],
"children": [
{
"type": "text",
"id": "kfn_inspector_title",
"content": "Inspector — Tab focuses rows, Enter starts editing",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
},
{
"type": "frame",
"id": "kfn_prop_focused",
"layout": "horizontal",
"width": "fill_container",
"height": 36,
"fill": "$--muted",
"cornerRadius": 4,
"padding": [0, 12],
"gap": 8,
"children": [
{
"type": "text",
"id": "kfn_prop_key",
"content": "Status",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 12
},
{
"type": "text",
"id": "kfn_prop_val",
"content": "In Progress (focused — ring visible)",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 12
}
]
}
]
}
]
}

View File

@@ -294,4 +294,22 @@ mod tests {
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(config["mcpServers"]["laputa"]["args"][0], "/test/index.js");
}
#[test]
fn upsert_returns_error_for_invalid_json() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
std::fs::write(&config_path, "not valid json{{{{").unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let result = upsert_mcp_config(&config_path, &entry);
assert!(result.is_err());
}
#[test]
fn register_mcp_to_configs_handles_empty_list() {
let entry = build_mcp_entry("/test/index.js", "/vault");
// Empty config list — function should return "registered" (no existing)
let status = register_mcp_to_configs(&entry, &[]);
// With empty config list, there were no updates, so status should be "registered"
assert_eq!(status, "registered");
}
}

View File

@@ -17,6 +17,11 @@ const VIEW_COMMAND_PALETTE: &str = "view-command-palette";
const VIEW_ZOOM_IN: &str = "view-zoom-in";
const VIEW_ZOOM_OUT: &str = "view-zoom-out";
const VIEW_ZOOM_RESET: &str = "view-zoom-reset";
const NOTE_ARCHIVE: &str = "note-archive";
const NOTE_TRASH: &str = "note-trash";
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
const VIEW_GO_BACK: &str = "view-go-back";
const VIEW_GO_FORWARD: &str = "view-go-forward";
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
@@ -24,6 +29,9 @@ const CUSTOM_IDS: &[&str] = &[
FILE_QUICK_OPEN,
FILE_SAVE,
FILE_CLOSE_TAB,
NOTE_ARCHIVE,
NOTE_TRASH,
EDIT_FIND_IN_VAULT,
VIEW_EDITOR_ONLY,
VIEW_EDITOR_LIST,
VIEW_ALL,
@@ -32,10 +40,12 @@ const CUSTOM_IDS: &[&str] = &[
VIEW_ZOOM_IN,
VIEW_ZOOM_OUT,
VIEW_ZOOM_RESET,
VIEW_GO_BACK,
VIEW_GO_FORWARD,
];
/// IDs of menu items that should be disabled when no note tab is active.
const NOTE_DEPENDENT_IDS: &[&str] = &[FILE_SAVE, FILE_CLOSE_TAB];
const NOTE_DEPENDENT_IDS: &[&str] = &[FILE_SAVE, FILE_CLOSE_TAB, NOTE_ARCHIVE, NOTE_TRASH];
type MenuResult = Result<Submenu<tauri::Wry>, Box<dyn std::error::Error>>;
@@ -77,6 +87,14 @@ fn build_file_menu(app: &App) -> MenuResult {
.id(FILE_CLOSE_TAB)
.accelerator("CmdOrCtrl+W")
.build(app)?;
let archive_note = MenuItemBuilder::new("Archive Note")
.id(NOTE_ARCHIVE)
.accelerator("CmdOrCtrl+E")
.build(app)?;
let trash_note = MenuItemBuilder::new("Trash Note")
.id(NOTE_TRASH)
.accelerator("CmdOrCtrl+Backspace")
.build(app)?;
Ok(SubmenuBuilder::new(app, "File")
.item(&new_note)
@@ -84,11 +102,19 @@ fn build_file_menu(app: &App) -> MenuResult {
.separator()
.item(&save)
.separator()
.item(&archive_note)
.item(&trash_note)
.separator()
.item(&close_tab)
.build()?)
}
fn build_edit_menu(app: &App) -> MenuResult {
let find_in_vault = MenuItemBuilder::new("Find in Vault")
.id(EDIT_FIND_IN_VAULT)
.accelerator("CmdOrCtrl+Shift+F")
.build(app)?;
Ok(SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
@@ -98,6 +124,8 @@ fn build_edit_menu(app: &App) -> MenuResult {
.paste()
.separator()
.select_all()
.separator()
.item(&find_in_vault)
.build()?)
}
@@ -133,6 +161,14 @@ fn build_view_menu(app: &App) -> MenuResult {
.id(VIEW_ZOOM_RESET)
.accelerator("CmdOrCtrl+0")
.build(app)?;
let go_back = MenuItemBuilder::new("Go Back")
.id(VIEW_GO_BACK)
.accelerator("CmdOrCtrl+[")
.build(app)?;
let go_forward = MenuItemBuilder::new("Go Forward")
.id(VIEW_GO_FORWARD)
.accelerator("CmdOrCtrl+]")
.build(app)?;
Ok(SubmenuBuilder::new(app, "View")
.item(&editor_only)
@@ -141,6 +177,9 @@ fn build_view_menu(app: &App) -> MenuResult {
.separator()
.item(&toggle_inspector)
.separator()
.item(&go_back)
.item(&go_forward)
.separator()
.item(&zoom_in)
.item(&zoom_out)
.item(&zoom_reset)
@@ -209,6 +248,9 @@ mod tests {
"file-quick-open",
"file-save",
"file-close-tab",
"note-archive",
"note-trash",
"edit-find-in-vault",
"view-editor-only",
"view-editor-list",
"view-all",
@@ -217,6 +259,8 @@ mod tests {
"view-zoom-in",
"view-zoom-out",
"view-zoom-reset",
"view-go-back",
"view-go-forward",
];
for id in &expected {
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");

View File

@@ -255,4 +255,17 @@ mod tests {
name
);
}
#[test]
fn test_qmd_uri_fallback() {
// Covers fallback branch when URI doesn't start with "qmd://"
let result = qmd_uri_to_vault_path("invalid-uri", "/vault");
assert!(result.contains("invalid-uri"));
}
#[test]
fn test_extract_clean_snippet_no_header() {
// No @@ header — content_start = 0
let snippet = extract_clean_snippet("plain content line");
assert_eq!(snippet, "plain content line");
}
}

View File

@@ -616,8 +616,15 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && editingKey !== propKey) {
e.preventDefault()
onStartEdit(propKey)
}
}
return (
<div className="group/prop flex min-w-0 items-center justify-between gap-2 rounded px-1.5 py-0.5 transition-colors hover:bg-muted" data-testid="editable-property">
<div className="group/prop flex min-w-0 items-center justify-between gap-2 rounded px-1.5 py-0.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
<span className="font-mono-overline flex min-w-0 items-center gap-1 text-muted-foreground">
<span className="truncate">{propKey}</span>
{onDelete && (

View File

@@ -52,10 +52,49 @@ const NOTE_STATUS_DOT: Record<string, { color: string; testId: string; title: st
modified: { color: 'var(--accent-orange)', testId: 'modified-indicator', title: 'Modified (uncommitted)' },
}
export function NoteItem({ entry, isSelected, isMultiSelected = false, noteStatus = 'clean', typeEntryMap, onClickNote }: {
function StatusDot({ noteStatus }: { noteStatus: NoteStatus }) {
const dot = NOTE_STATUS_DOT[noteStatus]
if (!dot) return null
return (
<span
className={`mr-1.5 inline-block align-middle${noteStatus === 'pendingSave' ? ' tab-status-pulse' : ''}`}
style={{ width: 6, height: 6, borderRadius: '50%', background: dot.color, verticalAlign: 'middle' }}
data-testid={dot.testId}
title={dot.title}
/>
)
}
function StateBadge({ archived, trashed }: { archived: boolean; trashed: boolean }) {
if (archived) {
return (
<span className="ml-1.5 inline-block align-middle text-muted-foreground" style={{ fontSize: 9, fontWeight: 500, background: 'var(--muted)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>
ARCHIVED
</span>
)
}
if (trashed) {
return (
<span className="ml-1.5 inline-block align-middle" style={{ fontSize: 9, fontWeight: 500, background: 'var(--destructive-light, #ef44441a)', color: 'var(--destructive)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>
TRASHED
</span>
)
}
return null
}
function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor: string, typeLightColor: string): React.CSSProperties {
const base: React.CSSProperties = { padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px' }
if (isMultiSelected) base.backgroundColor = 'color-mix(in srgb, var(--accent-blue) 10%, transparent)'
else if (isSelected) { base.borderLeftColor = typeColor; base.backgroundColor = typeLightColor }
return base
}
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', typeEntryMap, onClickNote }: {
entry: VaultEntry
isSelected: boolean
isMultiSelected?: boolean
isHighlighted?: boolean
noteStatus?: NoteStatus
typeEntryMap: Record<string, VaultEntry>
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
@@ -70,39 +109,21 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, noteStatu
className={cn(
"relative cursor-pointer border-b border-[var(--border)] transition-colors",
isSelected && !isMultiSelected && "border-l-[3px]",
!isSelected && !isMultiSelected && "hover:bg-muted"
!isSelected && !isMultiSelected && "hover:bg-muted",
isHighlighted && !isSelected && !isMultiSelected && "bg-muted"
)}
style={{
padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px',
...(isMultiSelected && { backgroundColor: 'color-mix(in srgb, var(--accent-blue) 10%, transparent)' }),
...(isSelected && !isMultiSelected && { borderLeftColor: typeColor, backgroundColor: typeLightColor }),
}}
style={noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)}
onClick={(e: React.MouseEvent) => onClickNote(entry, e)}
data-testid={isMultiSelected ? 'multi-selected-item' : undefined}
data-highlighted={isHighlighted || undefined}
>
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
<div className="pr-5">
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>
{noteStatus !== 'clean' && NOTE_STATUS_DOT[noteStatus] && (
<span
className={`mr-1.5 inline-block align-middle${noteStatus === 'pendingSave' ? ' tab-status-pulse' : ''}`}
style={{ width: 6, height: 6, borderRadius: '50%', background: NOTE_STATUS_DOT[noteStatus].color, verticalAlign: 'middle' }}
data-testid={NOTE_STATUS_DOT[noteStatus].testId}
title={NOTE_STATUS_DOT[noteStatus].title}
/>
)}
{noteStatus !== 'clean' && <StatusDot noteStatus={noteStatus} />}
{entry.title}
{entry.archived && (
<span className="ml-1.5 inline-block align-middle text-muted-foreground" style={{ fontSize: 9, fontWeight: 500, background: 'var(--muted)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>
ARCHIVED
</span>
)}
{entry.trashed && (
<span className="ml-1.5 inline-block align-middle" style={{ fontSize: 9, fontWeight: 500, background: 'var(--destructive-light, #ef44441a)', color: 'var(--destructive)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>
TRASHED
</span>
)}
<StateBadge archived={entry.archived} trashed={entry.trashed} />
</div>
</div>
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>

View File

@@ -1,6 +1,6 @@
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
import { useDragRegion } from '../hooks/useDragRegion'
import { Virtuoso } from 'react-virtuoso'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types'
import { Input } from '@/components/ui/input'
import {
@@ -11,6 +11,7 @@ import { NoteItem, getTypeIcon } from './NoteItem'
import { SortDropdown } from './SortDropdown'
import { BulkActionBar } from './BulkActionBar'
import { useMultiSelect } from '../hooks/useMultiSelect'
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
import {
type SortOption, type SortDirection, type SortConfig, type RelationshipGroup,
getSortComparator,
@@ -142,10 +143,11 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: {
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
}
function ListView({ isTrashView, isChangesView, expiredTrashCount, searched, query, renderItem }: {
function ListView({ isTrashView, isChangesView, expiredTrashCount, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isChangesView?: boolean; expiredTrashCount: number
searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
}) {
const emptyText = isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
const hasHeader = isTrashView && expiredTrashCount > 0
@@ -161,6 +163,7 @@ function ListView({ isTrashView, isChangesView, expiredTrashCount, searched, que
return (
<Virtuoso
ref={virtuosoRef}
style={{ height: '100%' }}
data={searched}
overscan={200}
@@ -302,6 +305,13 @@ 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 noteListKeyboard = useNoteListKeyboard({
items: searched,
selectedNotePath: selectedNote?.path ?? null,
onOpen: onReplaceActiveTab,
enabled: !isEntityView,
})
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
// Clear multi-select when sidebar selection changes
@@ -356,8 +366,8 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
}, [multiSelect, isEntityView, handleBulkArchive, handleBulkTrash])
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths])
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath])
return (
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
@@ -387,11 +397,11 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
</div>
)}
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
<div className="flex-1 overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
{isEntityView && selection.kind === 'entity' ? (
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
) : (
<ListView isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} />
<ListView isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
)}
</div>

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useCallback } from 'react'
import { useState, useRef, useCallback, useEffect } from 'react'
import { X, Eye, EyeSlash, GithubLogo, SignOut, Check, Plus } from '@phosphor-icons/react'
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
import type { Settings, ThemeFile } from '../types'
@@ -127,6 +127,16 @@ function SettingsPanelInner({ settings, onSave, onClose, themeManager }: Omit<Se
const [githubToken, setGithubToken] = useState(settings.github_token)
const [githubUsername, setGithubUsername] = useState(settings.github_username)
const [pullInterval, setPullInterval] = useState(settings.auto_pull_interval_minutes ?? 5)
const panelRef = useRef<HTMLDivElement>(null)
// Auto-focus first input when settings panel opens
useEffect(() => {
const timer = setTimeout(() => {
const input = panelRef.current?.querySelector('input')
input?.focus()
}, 50)
return () => clearTimeout(timer)
}, [])
const buildSettings = useCallback((ghOverride?: { token: string | null; username: string | null }): Settings => ({
anthropic_key: anthropicKey.trim() || null,
@@ -174,6 +184,7 @@ function SettingsPanelInner({ settings, onSave, onClose, themeManager }: Omit<Se
data-testid="settings-panel"
>
<div
ref={panelRef}
className="bg-background border border-border rounded-lg shadow-xl"
style={{ width: 520, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
>

View File

@@ -82,6 +82,11 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,
onArchiveNote: config.onArchiveNote,
onTrashNote: config.onTrashNote,
onSearch: config.onSearch,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
activeTabPath: config.activeTabPath,

View File

@@ -13,6 +13,11 @@ function makeHandlers(): MenuEventHandlers {
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
onArchiveNote: vi.fn(),
onTrashNote: vi.fn(),
onSearch: vi.fn(),
onGoBack: vi.fn(),
onGoForward: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
activeTabPath: '/vault/test.md',
@@ -105,6 +110,50 @@ describe('dispatchMenuEvent', () => {
expect(h.onZoomReset).toHaveBeenCalled()
})
it('note-archive triggers archive on active tab', () => {
const h = makeHandlers()
dispatchMenuEvent('note-archive', h)
expect(h.onArchiveNote).toHaveBeenCalledWith('/vault/test.md')
})
it('note-archive does nothing when no active tab', () => {
const h = makeHandlers()
h.activeTabPathRef = { current: null }
dispatchMenuEvent('note-archive', h)
expect(h.onArchiveNote).not.toHaveBeenCalled()
})
it('note-trash triggers trash on active tab', () => {
const h = makeHandlers()
dispatchMenuEvent('note-trash', h)
expect(h.onTrashNote).toHaveBeenCalledWith('/vault/test.md')
})
it('note-trash does nothing when no active tab', () => {
const h = makeHandlers()
h.activeTabPathRef = { current: null }
dispatchMenuEvent('note-trash', h)
expect(h.onTrashNote).not.toHaveBeenCalled()
})
it('edit-find-in-vault triggers search', () => {
const h = makeHandlers()
dispatchMenuEvent('edit-find-in-vault', h)
expect(h.onSearch).toHaveBeenCalled()
})
it('view-go-back triggers go back', () => {
const h = makeHandlers()
dispatchMenuEvent('view-go-back', h)
expect(h.onGoBack).toHaveBeenCalled()
})
it('view-go-forward triggers go forward', () => {
const h = makeHandlers()
dispatchMenuEvent('view-go-forward', h)
expect(h.onGoForward).toHaveBeenCalled()
})
it('unknown event ID does nothing', () => {
const h = makeHandlers()
dispatchMenuEvent('unknown-event', h)

View File

@@ -13,6 +13,11 @@ export interface MenuEventHandlers {
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
onArchiveNote: (path: string) => void
onTrashNote: (path: string) => void
onSearch: () => void
onGoBack?: () => void
onGoForward?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
activeTabPath: string | null
@@ -24,7 +29,7 @@ const VIEW_MODE_MAP: Record<string, ViewMode> = {
'view-all': 'all',
}
type SimpleHandler = 'onCreateNote' | 'onQuickOpen' | 'onSave' | 'onOpenSettings' | 'onToggleInspector' | 'onCommandPalette' | 'onZoomIn' | 'onZoomOut' | 'onZoomReset'
type SimpleHandler = 'onCreateNote' | 'onQuickOpen' | 'onSave' | 'onOpenSettings' | 'onToggleInspector' | 'onCommandPalette' | 'onZoomIn' | 'onZoomOut' | 'onZoomReset' | 'onSearch'
const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
'file-new-note': 'onCreateNote',
@@ -36,6 +41,22 @@ const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
'view-zoom-in': 'onZoomIn',
'view-zoom-out': 'onZoomOut',
'view-zoom-reset': 'onZoomReset',
'edit-find-in-vault': 'onSearch',
}
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
const path = h.activeTabPathRef.current
if (!path) return id === 'note-archive' || id === 'note-trash' || id === 'file-close-tab'
if (id === 'note-archive') { h.onArchiveNote(path); return true }
if (id === 'note-trash') { h.onTrashNote(path); return true }
if (id === 'file-close-tab') { h.handleCloseTabRef.current(path); return true }
return false
}
function dispatchOptionalEvent(id: string, h: MenuEventHandlers): boolean {
if (id === 'view-go-back') { h.onGoBack?.(); return true }
if (id === 'view-go-forward') { h.onGoForward?.(); return true }
return false
}
/** Dispatch a Tauri menu event ID to the matching handler. Exported for testing. */
@@ -46,10 +67,8 @@ export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
const simple = SIMPLE_EVENT_MAP[id]
if (simple) { h[simple](); return }
if (id === 'file-close-tab') {
const path = h.activeTabPathRef.current
if (path) h.handleCloseTabRef.current(path)
}
if (dispatchActiveTabEvent(id, h)) return
dispatchOptionalEvent(id, h)
}
/** Listen for native macOS menu events and dispatch them to the appropriate handlers. */

View File

@@ -0,0 +1,160 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { useNoteListKeyboard } from './useNoteListKeyboard'
import type { VaultEntry } from '../types'
function makeEntry(path: string, title: string): VaultEntry {
return {
path,
title,
filename: `${title}.md`,
isA: 'Note',
aliases: [],
tags: [],
snippet: '',
status: null,
favorite: false,
archived: false,
trashed: false,
trashedAt: null,
createdAt: null,
modifiedAt: null,
fileSize: 100,
color: null,
icon: null,
outgoingLinks: [],
relationships: {},
}
}
function keyEvent(key: string, opts: Partial<React.KeyboardEvent> = {}): React.KeyboardEvent {
return { key, preventDefault: vi.fn(), metaKey: false, ctrlKey: false, altKey: false, ...opts } as unknown as React.KeyboardEvent
}
describe('useNoteListKeyboard', () => {
const items = [makeEntry('/a.md', 'A'), makeEntry('/b.md', 'B'), makeEntry('/c.md', 'C')]
const onOpen = vi.fn()
it('initializes with no highlight', () => {
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
)
expect(result.current.highlightedPath).toBeNull()
})
it('ArrowDown highlights first item from no selection', () => {
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/a.md')
})
it('ArrowDown advances highlight', () => {
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/b.md')
})
it('ArrowDown clamps at end of list', () => {
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/c.md')
})
it('ArrowUp highlights last item from no selection', () => {
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowUp')))
expect(result.current.highlightedPath).toBe('/c.md')
})
it('ArrowUp clamps at start of list', () => {
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowUp')))
expect(result.current.highlightedPath).toBe('/a.md')
})
it('Enter opens highlighted note', () => {
const open = vi.fn()
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen: open, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('Enter')))
expect(open).toHaveBeenCalledWith(items[0])
})
it('Enter does nothing when no item highlighted', () => {
const open = vi.fn()
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen: open, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('Enter')))
expect(open).not.toHaveBeenCalled()
})
it('does nothing when disabled', () => {
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: false }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBeNull()
})
it('does nothing with modifier keys', () => {
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown', { metaKey: true } as Partial<React.KeyboardEvent>)))
expect(result.current.highlightedPath).toBeNull()
})
it('resets highlight when items change', () => {
const { result, rerender } = renderHook(
({ items: hookItems }) => useNoteListKeyboard({ items: hookItems, selectedNotePath: null, onOpen, enabled: true }),
{ initialProps: { items } },
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/a.md')
rerender({ items: [makeEntry('/d.md', 'D')] })
expect(result.current.highlightedPath).toBeNull()
})
it('handleFocus sets highlight to selected note', () => {
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: '/b.md', onOpen, enabled: true }),
)
act(() => result.current.handleFocus())
expect(result.current.highlightedPath).toBe('/b.md')
})
it('handleFocus defaults to first item when no selected note', () => {
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
)
act(() => result.current.handleFocus())
expect(result.current.highlightedPath).toBe('/a.md')
})
it('does nothing on empty item list', () => {
const { result } = renderHook(() =>
useNoteListKeyboard({ items: [], selectedNotePath: null, onOpen, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBeNull()
})
})

View File

@@ -0,0 +1,60 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import type { VirtuosoHandle } from 'react-virtuoso'
import type { VaultEntry } from '../types'
interface NoteListKeyboardOptions {
items: VaultEntry[]
selectedNotePath: string | null
onOpen: (entry: VaultEntry) => void
enabled: boolean
}
export function useNoteListKeyboard({
items, selectedNotePath, onOpen, enabled,
}: NoteListKeyboardOptions) {
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const virtuosoRef = useRef<VirtuosoHandle>(null)
// Reset highlight when items change (filter/sort/selection changed)
useEffect(() => {
setHighlightedIndex(-1) // eslint-disable-line react-hooks/set-state-in-effect -- reset on data change
}, [items])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!enabled || items.length === 0) return
if (e.metaKey || e.ctrlKey || e.altKey) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlightedIndex(prev => {
const next = Math.min((prev < 0 ? -1 : prev) + 1, items.length - 1)
virtuosoRef.current?.scrollToIndex({ index: next, behavior: 'auto' })
return next
})
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlightedIndex(prev => {
const next = Math.max((prev < 0 ? items.length : prev) - 1, 0)
virtuosoRef.current?.scrollToIndex({ index: next, behavior: 'auto' })
return next
})
} else if (e.key === 'Enter' && highlightedIndex >= 0 && highlightedIndex < items.length) {
e.preventDefault()
onOpen(items[highlightedIndex])
}
}, [enabled, items, highlightedIndex, onOpen])
const handleFocus = useCallback(() => {
if (highlightedIndex >= 0 || items.length === 0) return
const activeIdx = selectedNotePath
? items.findIndex(n => n.path === selectedNotePath)
: -1
setHighlightedIndex(activeIdx >= 0 ? activeIdx : 0)
}, [highlightedIndex, items, selectedNotePath])
const highlightedPath = (highlightedIndex >= 0 && highlightedIndex < items.length)
? items[highlightedIndex].path
: null
return { highlightedPath, handleKeyDown, handleFocus, virtuosoRef }
}