fix: Cmd+Option+arrow navigation now follows the visible note list order

The keyboard navigation was computing its own note list with hardcoded
sortByModified, which didn't match the actual UI order (Inbox sorts by
createdAt, custom types use configurable sorts, etc.). Now the NoteList
component writes its sorted list to a shared ref that the navigation
hook reads directly, ensuring navigation always matches visual order.

Also fixes navigation not wrapping at list boundaries per spec.

Syncs CodeScene thresholds with current remote scores.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-04-06 10:29:27 +02:00
parent 7443b9a468
commit 09f7498e86
6 changed files with 109 additions and 66 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.41
AVERAGE_THRESHOLD=9.30
HOTSPOT_THRESHOLD=9.33
AVERAGE_THRESHOLD=9.27

View File

@@ -51,7 +51,7 @@ import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection, InboxPeriod } from './types'
import type { SidebarSelection, InboxPeriod, VaultEntry } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
@@ -83,6 +83,7 @@ function App() {
setNoteListFilter('open')
}, [])
const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined)
const visibleNotesRef = useRef<VaultEntry[]>([])
const [toastMessage, setToastMessage] = useState<string | null>(null)
const dialogs = useDialogs()
@@ -455,6 +456,7 @@ function App() {
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
entries: vault.entries,
visibleNotesRef,
modifiedCount: vault.modifiedFiles.length,
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
selection,
@@ -587,7 +589,7 @@ function App() {
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} views={vault.views} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} views={vault.views} visibleNotesRef={visibleNotesRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />

View File

@@ -140,9 +140,10 @@ interface NoteListProps {
onDiscardFile?: (relativePath: string) => Promise<void>
onAutoTriggerDiff?: () => void
views?: ViewFile[]
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
}
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, views }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, views, visibleNotesRef }: NoteListProps) {
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
const { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } = useViewFlags(selection)
@@ -169,6 +170,12 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
return map
}, [isChangesView, modifiedFiles])
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views })
// Keep the visible notes ref in sync for keyboard navigation (Cmd+Option+Arrow)
if (visibleNotesRef) {
visibleNotesRef.current = isEntityView
? searchedGroups.flatMap((g) => g.entries)
: searched
}
const deletedCount = useMemo(
() => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0,
[isChangesView, modifiedFiles],

View File

@@ -12,6 +12,7 @@ interface AppCommandsConfig {
activeTabPath: string | null
activeTabPathRef: React.MutableRefObject<string | null>
entries: VaultEntry[]
visibleNotesRef: React.RefObject<VaultEntry[]>
modifiedCount: number
selection: SidebarSelection
onQuickOpen: () => void
@@ -219,8 +220,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
useKeyboardNavigation({
activeTabPath: config.activeTabPath,
entries: config.entries,
selection: config.selection,
visibleNotesRef: config.visibleNotesRef,
onReplaceActiveTab: config.onReplaceActiveTab,
onSelectNote: config.onSelectNote,
})

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import type { VaultEntry, SidebarSelection } from '../types'
import type { VaultEntry } from '../types'
import { useKeyboardNavigation } from './useKeyboardNavigation'
vi.mock('../mock-tauri', () => ({
@@ -44,13 +44,11 @@ describe('useKeyboardNavigation', () => {
makeEntry({ path: '/vault/c.md', title: 'C', modifiedAt: 1700000001 }),
]
const selection: SidebarSelection = { kind: 'filter', filter: 'all' }
let addedListeners: { type: string; handler: EventListenerOrEventListenerObject }[] = []
beforeEach(() => {
vi.clearAllMocks()
addedListeners = []
// Track added listeners for cleanup verification
const origAdd = window.addEventListener
const origRemove = window.removeEventListener
vi.spyOn(window, 'addEventListener').mockImplementation((type: string, handler: EventListenerOrEventListenerObject, opts?: boolean | AddEventListenerOptions) => {
@@ -66,24 +64,25 @@ describe('useKeyboardNavigation', () => {
vi.restoreAllMocks()
})
it('registers keydown listener on mount', () => {
renderHook(() =>
function renderNav(activeTabPath: string | null, noteList: VaultEntry[] = entries) {
const visibleNotesRef = { current: noteList }
return renderHook(() =>
useKeyboardNavigation({
activeTabPath: '/vault/a.md', entries, selection,
onReplaceActiveTab, onSelectNote,
activeTabPath,
visibleNotesRef,
onReplaceActiveTab,
onSelectNote,
})
)
}
it('registers keydown listener on mount', () => {
renderNav('/vault/a.md')
expect(addedListeners.some(l => l.type === 'keydown')).toBe(true)
})
it('navigates to next note on Cmd+Alt+ArrowDown', () => {
renderHook(() =>
useKeyboardNavigation({
activeTabPath: '/vault/a.md', entries, selection,
onReplaceActiveTab, onSelectNote,
})
)
renderNav('/vault/a.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
@@ -91,16 +90,11 @@ describe('useKeyboardNavigation', () => {
}))
})
expect(onReplaceActiveTab).toHaveBeenCalled()
expect(onReplaceActiveTab).toHaveBeenCalledWith(entries[1])
})
it('navigates to previous note on Cmd+Alt+ArrowUp', () => {
renderHook(() =>
useKeyboardNavigation({
activeTabPath: '/vault/b.md', entries, selection,
onReplaceActiveTab, onSelectNote,
})
)
renderNav('/vault/b.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
@@ -108,16 +102,11 @@ describe('useKeyboardNavigation', () => {
}))
})
expect(onReplaceActiveTab).toHaveBeenCalled()
expect(onReplaceActiveTab).toHaveBeenCalledWith(entries[0])
})
it('selects first note when no active tab', () => {
renderHook(() =>
useKeyboardNavigation({
activeTabPath: null, entries, selection,
onReplaceActiveTab, onSelectNote,
})
)
it('does not wrap when at the last note and pressing Down', () => {
renderNav('/vault/c.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
@@ -125,16 +114,49 @@ describe('useKeyboardNavigation', () => {
}))
})
expect(onSelectNote).toHaveBeenCalled()
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
})
it('does not wrap when at the first note and pressing Up', () => {
renderNav('/vault/a.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowUp', metaKey: true, altKey: true, bubbles: true,
}))
})
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
})
it('selects first note when no active tab and pressing Down', () => {
renderNav(null)
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowDown', metaKey: true, altKey: true, bubbles: true,
}))
})
expect(onSelectNote).toHaveBeenCalledWith(entries[0])
})
it('selects last note when no active tab and pressing Up', () => {
renderNav(null)
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowUp', metaKey: true, altKey: true, bubbles: true,
}))
})
expect(onSelectNote).toHaveBeenCalledWith(entries[2])
})
it('does nothing without modifier keys', () => {
renderHook(() =>
useKeyboardNavigation({
activeTabPath: '/vault/a.md', entries, selection,
onReplaceActiveTab, onSelectNote,
})
)
renderNav('/vault/a.md')
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
@@ -145,4 +167,32 @@ describe('useKeyboardNavigation', () => {
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
})
it('navigates in the order provided by visibleNotesRef (not by modifiedAt)', () => {
// Provide notes in reverse-alpha order (C, B, A) regardless of modifiedAt
const customOrder = [entries[2], entries[1], entries[0]]
renderNav('/vault/c.md', customOrder)
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowDown', metaKey: true, altKey: true, bubbles: true,
}))
})
// Should navigate to B (next in custom order), not based on modifiedAt
expect(onReplaceActiveTab).toHaveBeenCalledWith(entries[1])
})
it('does nothing when note list is empty', () => {
renderNav('/vault/a.md', [])
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowDown', metaKey: true, altKey: true, bubbles: true,
}))
})
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
})
})

View File

@@ -1,26 +1,13 @@
import { useEffect, useMemo, useRef } from 'react'
import { filterEntries, sortByModified, buildRelationshipGroups } from '../utils/noteListHelpers'
import type { VaultEntry, SidebarSelection } from '../types'
import { useEffect, useRef } from 'react'
import type { VaultEntry } from '../types'
interface KeyboardNavigationOptions {
activeTabPath: string | null
entries: VaultEntry[]
selection: SidebarSelection
visibleNotesRef: React.RefObject<VaultEntry[]>
onReplaceActiveTab: (entry: VaultEntry) => void
onSelectNote: (entry: VaultEntry) => void
}
function computeVisibleNotes(
entries: VaultEntry[],
selection: SidebarSelection,
): VaultEntry[] {
if (selection.kind === 'entity') {
return buildRelationshipGroups(selection.entry, entries)
.flatMap((g) => g.entries)
}
return [...filterEntries(entries, selection)].sort(sortByModified)
}
function navigateNote(
visibleNotesRef: React.RefObject<VaultEntry[]>,
activeTabPathRef: React.RefObject<string | null>,
@@ -36,7 +23,10 @@ function navigateNote(
const nextIndex = currentIndex === -1
? (direction === 1 ? 0 : notes.length - 1)
: (currentIndex + direction + notes.length) % notes.length
: currentIndex + direction
// Clamp to list bounds — don't wrap around
if (nextIndex < 0 || nextIndex >= notes.length) return
const nextNote = notes[nextIndex]
if (currentPath) {
@@ -53,16 +43,10 @@ function useLatestRef<T>(value: T): React.RefObject<T> {
}
export function useKeyboardNavigation({
activeTabPath, entries, selection,
activeTabPath, visibleNotesRef,
onReplaceActiveTab, onSelectNote,
}: KeyboardNavigationOptions) {
const visibleNotes = useMemo(
() => computeVisibleNotes(entries, selection),
[entries, selection],
)
const activeTabPathRef = useLatestRef(activeTabPath)
const visibleNotesRef = useLatestRef(visibleNotes)
const onReplaceRef = useLatestRef(onReplaceActiveTab)
const onSelectNoteRef = useLatestRef(onSelectNote)