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:
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.41
|
||||
AVERAGE_THRESHOLD=9.30
|
||||
HOTSPOT_THRESHOLD=9.33
|
||||
AVERAGE_THRESHOLD=9.27
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user