Compare commits

...

4 Commits

Author SHA1 Message Date
Test
b8983f16b2 fix: move cmd+e to toggle organized 2026-04-10 12:08:17 +02:00
Test
2fabc2a1d7 fix: restore filter field dropdown scrolling 2026-04-10 11:53:07 +02:00
Test
2f32a1781a chore: ratchet codescene thresholds to 9.68/9.33 2026-04-10 11:43:36 +02:00
Test
94ce91457d fix: support localized ai panel shortcut 2026-04-10 11:37:52 +02:00
15 changed files with 197 additions and 51 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.68
AVERAGE_THRESHOLD=9.32
AVERAGE_THRESHOLD=9.33

View File

@@ -35,6 +35,7 @@ const GO_ARCHIVED: &str = "go-archived";
const GO_CHANGES: &str = "go-changes";
const GO_INBOX: &str = "go-inbox";
const NOTE_TOGGLE_ORGANIZED: &str = "note-toggle-organized";
const NOTE_ARCHIVE: &str = "note-archive";
const NOTE_DELETE: &str = "note-delete";
const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window";
@@ -77,6 +78,7 @@ const CUSTOM_IDS: &[&str] = &[
GO_ALL_NOTES,
GO_ARCHIVED,
GO_CHANGES,
NOTE_TOGGLE_ORGANIZED,
NOTE_ARCHIVE,
NOTE_DELETE,
NOTE_OPEN_IN_NEW_WINDOW,
@@ -96,6 +98,7 @@ const CUSTOM_IDS: &[&str] = &[
/// IDs of menu items that should be disabled when no note tab is active.
const NOTE_DEPENDENT_IDS: &[&str] = &[
FILE_SAVE,
NOTE_TOGGLE_ORGANIZED,
NOTE_ARCHIVE,
NOTE_DELETE,
EDIT_TOGGLE_RAW_EDITOR,
@@ -273,9 +276,12 @@ fn build_go_menu(app: &App) -> MenuResult {
}
fn build_note_menu(app: &App) -> MenuResult {
let toggle_organized = MenuItemBuilder::new("Toggle Organized")
.id(NOTE_TOGGLE_ORGANIZED)
.accelerator("CmdOrCtrl+E")
.build(app)?;
let archive_note = MenuItemBuilder::new("Archive Note")
.id(NOTE_ARCHIVE)
.accelerator("CmdOrCtrl+E")
.build(app)?;
let delete_note = MenuItemBuilder::new("Delete Note")
.id(NOTE_DELETE)
@@ -302,6 +308,7 @@ fn build_note_menu(app: &App) -> MenuResult {
.build(app)?;
Ok(SubmenuBuilder::new(app, "Note")
.item(&toggle_organized)
.item(&archive_note)
.item(&delete_note)
.item(&restore_deleted_note)

View File

@@ -9,12 +9,12 @@ describe('ArchivedNoteBanner', () => {
expect(screen.getByText('Archived')).toBeTruthy()
})
it('renders unarchive button with keyboard hint', () => {
it('renders unarchive button without the stale archive shortcut hint', () => {
render(<ArchivedNoteBanner onUnarchive={vi.fn()} />)
const btn = screen.getByTestId('unarchive-btn')
expect(btn).toBeTruthy()
expect(btn.textContent).toContain('Unarchive')
expect(btn.title).toBe('Unarchive (Cmd+E)')
expect(btn.title).toBe('Unarchive')
})
it('calls onUnarchive when button is clicked', () => {

View File

@@ -38,7 +38,7 @@ export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
color: 'var(--muted-foreground)',
cursor: 'pointer',
}}
title="Unarchive (Cmd+E)"
title="Unarchive"
>
<ArrowUUpLeft size={12} />
Unarchive

View File

@@ -74,31 +74,38 @@ describe('BreadcrumbBar — delete', () => {
describe('BreadcrumbBar — archive/unarchive', () => {
it('shows archive button for non-archived note', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
expect(screen.getByTitle('Archive (Cmd+E)')).toBeInTheDocument()
expect(screen.queryByTitle('Unarchive (Cmd+E)')).not.toBeInTheDocument()
expect(screen.getByTitle('Archive')).toBeInTheDocument()
expect(screen.queryByTitle('Unarchive')).not.toBeInTheDocument()
})
it('shows unarchive button for archived note', () => {
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
expect(screen.getByTitle('Unarchive (Cmd+E)')).toBeInTheDocument()
expect(screen.queryByTitle('Archive (Cmd+E)')).not.toBeInTheDocument()
expect(screen.getByTitle('Unarchive')).toBeInTheDocument()
expect(screen.queryByTitle('Archive')).not.toBeInTheDocument()
})
it('calls onArchive when archive button is clicked', () => {
const onArchive = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={onArchive} />)
fireEvent.click(screen.getByTitle('Archive (Cmd+E)'))
fireEvent.click(screen.getByTitle('Archive'))
expect(onArchive).toHaveBeenCalledOnce()
})
it('calls onUnarchive when unarchive button is clicked', () => {
const onUnarchive = vi.fn()
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onUnarchive={onUnarchive} />)
fireEvent.click(screen.getByTitle('Unarchive (Cmd+E)'))
fireEvent.click(screen.getByTitle('Unarchive'))
expect(onUnarchive).toHaveBeenCalledOnce()
})
})
describe('BreadcrumbBar — organized shortcut hint', () => {
it('shows Cmd+E on the organized toggle tooltip', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleOrganized={vi.fn()} />)
expect(screen.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeInTheDocument()
})
})
describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', () => {
it('always renders title elements in the DOM', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)

View File

@@ -81,7 +81,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
entry.organized ? "text-green-600" : "text-muted-foreground hover:text-foreground"
)}
onClick={onToggleOrganized}
title={entry.organized ? 'Mark as unorganized (back to Inbox)' : 'Mark as organized (remove from Inbox)'}
title={entry.organized ? 'Mark as unorganized (back to Inbox) (Cmd+E)' : 'Mark as organized (remove from Inbox) (Cmd+E)'}
>
<CheckCircle size={16} weight={entry.organized ? 'fill' : 'regular'} />
</button>
@@ -137,7 +137,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
onClick={onUnarchive}
title="Unarchive (Cmd+E)"
title="Unarchive"
>
<ArrowUUpLeft size={16} />
</button>
@@ -145,7 +145,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
<button
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
onClick={onArchive}
title="Archive (Cmd+E)"
title="Archive"
>
<Archive size={16} />
</button>

View File

@@ -54,6 +54,62 @@ function stepHighlightedIndex(current: number, optionCount: number, direction: '
return (current - 1 + optionCount) % optionCount
}
function moveHighlightedOption({
event,
open,
options,
direction,
openCombobox,
setHighlightedIndex,
}: {
event: KeyboardEvent<HTMLInputElement>
open: boolean
options: string[]
direction: 'next' | 'previous'
openCombobox: () => void
setHighlightedIndex: (updater: number | ((current: number) => number)) => void
}) {
event.preventDefault()
if (!open) {
openCombobox()
return
}
if (options.length === 0) return
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, direction))
}
function selectHighlightedOption({
event,
open,
options,
highlightedIndex,
selectOption,
}: {
event: KeyboardEvent<HTMLInputElement>
open: boolean
options: string[]
highlightedIndex: number
selectOption: (value: string) => void
}) {
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
event.preventDefault()
selectOption(options[highlightedIndex])
}
function closeOpenCombobox({
event,
open,
closeCombobox,
}: {
event: KeyboardEvent<HTMLInputElement>
open: boolean
closeCombobox: () => void
}) {
if (!open) return
event.preventDefault()
closeCombobox()
}
function handleFilterFieldKeyDown({
event,
open,
@@ -75,32 +131,16 @@ function handleFilterFieldKeyDown({
}) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
if (!open) {
openCombobox()
return
}
if (options.length === 0) return
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'next'))
moveHighlightedOption({ event, open, options, direction: 'next', openCombobox, setHighlightedIndex })
return
case 'ArrowUp':
event.preventDefault()
if (!open) {
openCombobox()
return
}
if (options.length === 0) return
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'previous'))
moveHighlightedOption({ event, open, options, direction: 'previous', openCombobox, setHighlightedIndex })
return
case 'Enter':
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
event.preventDefault()
selectOption(options[highlightedIndex])
selectHighlightedOption({ event, open, options, highlightedIndex, selectOption })
return
case 'Escape':
if (!open) return
event.preventDefault()
closeCombobox()
closeOpenCombobox({ event, open, closeCombobox })
return
default:
return
@@ -178,21 +218,27 @@ function FilterFieldPopoverPanel({
<PopoverContent
align="start"
sideOffset={4}
className="max-h-60 overflow-y-auto p-1"
className="overflow-hidden p-1"
style={{ width: contentWidth }}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
data-testid="filter-field-combobox-popover"
>
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
<FilterFieldOptionsList
listboxId={listboxId}
fieldGroups={fieldGroups}
options={options}
highlightedIndex={highlightedIndex}
onHighlight={onHighlight}
onSelect={onSelect}
/>
<div
className="max-h-60 overflow-y-auto overscroll-contain"
data-testid="filter-field-combobox-scroll-area"
onWheelCapture={(event) => event.stopPropagation()}
>
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
<FilterFieldOptionsList
listboxId={listboxId}
fieldGroups={fieldGroups}
options={options}
highlightedIndex={highlightedIndex}
onHighlight={onHighlight}
onSelect={onSelect}
/>
</div>
</div>
</PopoverContent>
)

View File

@@ -19,4 +19,41 @@ describe('FilterFieldCombobox', () => {
expect(listbox).toBeInTheDocument()
expect(root.contains(listbox)).toBe(false)
})
it('renders the option list inside a dedicated scroll container', () => {
render(
<FilterFieldCombobox
value="status"
fields={Array.from({ length: 20 }, (_, index) => `Field ${index + 1}`)}
onChange={vi.fn()}
/>,
)
fireEvent.focus(screen.getByTestId('filter-field-combobox-input'))
expect(screen.getByTestId('filter-field-combobox-scroll-area')).toHaveClass(
'max-h-60',
'overflow-y-auto',
'overscroll-contain',
)
})
it('stops wheel events from bubbling out of the scroll container', () => {
const onWheel = vi.fn()
render(
<div onWheel={onWheel}>
<FilterFieldCombobox
value="status"
fields={Array.from({ length: 20 }, (_, index) => `Field ${index + 1}`)}
onChange={vi.fn()}
/>
</div>,
)
fireEvent.focus(screen.getByTestId('filter-field-combobox-input'))
fireEvent.wheel(screen.getByTestId('filter-field-combobox-scroll-area'))
expect(onWheel).not.toHaveBeenCalled()
})
})

View File

@@ -22,6 +22,7 @@ export interface KeyboardActions {
onToggleRawEditor?: () => void
onToggleInspector?: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onOpenInNewWindow?: () => void
activeTabPathRef: MutableRefObject<string | null>
}
@@ -77,7 +78,7 @@ export function createCommandKeyMap(actions: KeyboardActions): ShortcutMap {
s: actions.onSave,
',': actions.onOpenSettings,
d: withActiveTab(activeTabPathRef, (path) => actions.onToggleFavorite?.(path)),
e: withActiveTab(activeTabPathRef, actions.onArchiveNote),
e: withActiveTab(activeTabPathRef, (path) => actions.onToggleOrganized?.(path)),
Backspace: withActiveTab(activeTabPathRef, actions.onDeleteNote),
Delete: withActiveTab(activeTabPathRef, actions.onDeleteNote),
'[': () => actions.onGoBack?.(),
@@ -121,7 +122,8 @@ export function handleCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean
}
export function handleAiPanelKey(e: KeyboardEvent, onToggleAIChat?: () => void): boolean {
if (isCommandShiftOnly(e) === false || e.key.toLowerCase() !== 'l' || onToggleAIChat === undefined) return false
const matchesAiPanelShortcut = e.code === 'KeyL' || e.key.toLowerCase() === 'l'
if (isCommandShiftOnly(e) === false || matchesAiPanelShortcut === false || onToggleAIChat === undefined) return false
e.preventDefault()
onToggleAIChat()
return true

View File

@@ -45,7 +45,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
execute: () => { if (activeTabPath) onDeleteNote(activeTabPath) },
},
{
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E',
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note',
keywords: ['archive'], enabled: hasActiveNote,
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
},
@@ -62,7 +62,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
execute: () => { if (activeTabPath) onToggleFavorite?.(activeTabPath) },
},
{
id: 'toggle-organized', label: isOrganized ? 'Mark as Unorganized' : 'Mark as Organized', group: 'Note',
id: 'toggle-organized', label: isOrganized ? 'Mark as Unorganized' : 'Mark as Organized', group: 'Note', shortcut: '⌘E',
keywords: ['organized', 'inbox', 'triage', 'done'],
enabled: hasActiveNote && !!onToggleOrganized,
execute: () => { if (activeTabPath) onToggleOrganized?.(activeTabPath) },

View File

@@ -115,6 +115,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onToggleRawEditor: config.onToggleRawEditor,
onToggleInspector: config.onToggleInspector,
onToggleFavorite: config.onToggleFavorite,
onToggleOrganized: config.onToggleOrganized,
onOpenInNewWindow: config.onOpenInNewWindow,
activeTabPathRef: config.activeTabPathRef,
})
@@ -138,6 +139,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onToggleRawEditor: config.onToggleRawEditor,
onToggleDiff: config.onToggleDiff,
onToggleAIChat: config.onToggleAIChat,
onToggleOrganized: config.onToggleOrganized,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
onCheckForUpdates: config.onCheckForUpdates,

View File

@@ -2,17 +2,21 @@ import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useAppKeyboard } from './useAppKeyboard'
function fireKey(key: string, mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {}) {
function fireKey(
key: string,
mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; code?: string } = {},
) {
fireKeyOnTarget(window, key, mods)
}
function fireKeyOnTarget(
target: EventTarget,
key: string,
mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {},
mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; code?: string } = {},
) {
const event = new KeyboardEvent('keydown', {
key,
code: mods.code,
altKey: mods.altKey ?? false,
metaKey: mods.metaKey ?? false,
ctrlKey: mods.ctrlKey ?? false,
@@ -34,6 +38,7 @@ function makeActions() {
onOpenSettings: vi.fn(),
onDeleteNote: vi.fn(),
onArchiveNote: vi.fn(),
onToggleOrganized: vi.fn(),
onSetViewMode: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
@@ -95,6 +100,14 @@ describe('useAppKeyboard', () => {
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
})
it('Cmd+E triggers toggle organized on active note, not archive', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('e', { metaKey: true })
expect(actions.onToggleOrganized).toHaveBeenCalledWith('/vault/test.md')
expect(actions.onArchiveNote).not.toHaveBeenCalled()
})
it('Cmd+J triggers open daily note', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
@@ -237,6 +250,14 @@ describe('useAppKeyboard', () => {
})
})
it('Cmd+Shift+L matches by physical key code when the localized key differs', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
fireKey('¬', { code: 'KeyL', metaKey: true, shiftKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
it('Ctrl+Shift+L does not trigger toggle AI chat', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()

View File

@@ -16,6 +16,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
onDeleteNote: vi.fn(),
onArchiveNote: vi.fn(),
onUnarchiveNote: vi.fn(),
onToggleOrganized: vi.fn(),
onCommitPush: vi.fn(),
onResolveConflicts: vi.fn(),
onSetViewMode: vi.fn(),
@@ -192,6 +193,13 @@ describe('useCommandRegistry', () => {
const cmd = findCommand(result.current, 'customize-inbox-columns')
expect(cmd!.enabled).toBe(false)
})
it('shows Cmd+E on toggle organized and removes it from archive note', () => {
const config = makeConfig()
const { result } = renderHook(() => useCommandRegistry(config))
expect(findCommand(result.current, 'toggle-organized')?.shortcut).toBe('⌘E')
expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined()
})
})
describe('pluralizeType', () => {

View File

@@ -15,6 +15,7 @@ function makeHandlers(): MenuEventHandlers {
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
onToggleOrganized: vi.fn(),
onArchiveNote: vi.fn(),
onDeleteNote: vi.fn(),
onSearch: vi.fn(),
@@ -143,6 +144,19 @@ describe('dispatchMenuEvent', () => {
expect(h.onArchiveNote).not.toHaveBeenCalled()
})
it('note-toggle-organized triggers organized toggle on active tab', () => {
const h = makeHandlers()
dispatchMenuEvent('note-toggle-organized', h)
expect(h.onToggleOrganized).toHaveBeenCalledWith('/vault/test.md')
})
it('note-toggle-organized does nothing when no active tab', () => {
const h = makeHandlers()
h.activeTabPathRef = { current: null }
dispatchMenuEvent('note-toggle-organized', h)
expect(h.onToggleOrganized).not.toHaveBeenCalled()
})
it('note-delete triggers delete on active tab', () => {
const h = makeHandlers()
dispatchMenuEvent('note-delete', h)

View File

@@ -16,6 +16,7 @@ export interface MenuEventHandlers {
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
onToggleOrganized?: (path: string) => void
onArchiveNote: (path: string) => void
onDeleteNote: (path: string) => void
onSearch: () => void
@@ -106,7 +107,8 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
const path = h.activeTabPathRef.current
if (!path) return id === 'note-archive' || id === 'note-delete'
if (!path) return id === 'note-toggle-organized' || id === 'note-archive' || id === 'note-delete'
if (id === 'note-toggle-organized') { h.onToggleOrganized?.(path); return true }
if (id === 'note-archive') { h.onArchiveNote(path); return true }
if (id === 'note-delete') { h.onDeleteNote(path); return true }
return false