Compare commits

...

6 Commits

Author SHA1 Message Date
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
Test
717d97f6f0 fix: keep date filter rows aligned 2026-04-09 13:39:08 +02:00
Test
727a1b95d0 fix: refresh changes after frontmatter edits 2026-04-09 13:29:33 +02:00
Test
000d89df50 fix: make ai panel shortcut command-only 2026-04-09 13:16:35 +02:00
14 changed files with 286 additions and 59 deletions

View File

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

View File

@@ -295,7 +295,7 @@ fn build_note_menu(app: &App) -> MenuResult {
.build(app)?;
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
.id(VIEW_TOGGLE_AI_CHAT)
.accelerator("CmdOrCtrl+Shift+L")
.accelerator("Cmd+Shift+L")
.build(app)?;
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
.id(VIEW_TOGGLE_BACKLINKS)

View File

@@ -187,7 +187,7 @@ function App() {
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
})
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry, onFrontmatterPersisted: vault.loadModifiedFiles })
// Note window: auto-open the note from URL params once vault entries load
const noteWindowOpenedRef = useRef(false)
@@ -381,7 +381,6 @@ function App() {
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
createTypeEntry: notes.createTypeEntrySilent,
onFrontmatterPersisted: vault.loadModifiedFiles,
onBeforeAction: appSave.flushBeforeAction,
})

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { FilterBuilder } from './FilterBuilder'
import type { FilterGroup } from '../types'
@@ -135,6 +135,23 @@ describe('FilterBuilder value inputs', () => {
expect(screen.getByTestId('date-picker-trigger')).toHaveAttribute('title', 'Mar 28, 2026')
})
it('keeps filter controls top-aligned when the date preview adds a second line', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-07T12:00:00Z'))
renderBuilder({
all: [{ field: 'created', op: 'after', value: '10 days ago' }],
})
fireEvent.focus(screen.getByTestId('date-value-input'))
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
})
expect(screen.getByTestId('date-value-preview')).toHaveTextContent('Resolves to March 28, 2026')
expect(screen.getByTestId('filter-row')).toHaveClass('items-start')
})
it('filters the field combobox as the user types', () => {
render(
<FilterBuilder

View File

@@ -141,7 +141,7 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
const regexEnabled = regexSupported && condition.regex === true
const invalidRegex = regexSupported && hasInvalidRegex(String(condition.value ?? ''), regexEnabled)
return (
<div className="flex items-center gap-1.5">
<div className="flex items-start gap-1.5" data-testid="filter-row">
<FilterFieldCombobox
value={condition.field}
fields={fields}

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

@@ -38,8 +38,10 @@ const VIEW_MODE_KEYS: Record<string, ViewMode> = {
}
function isTextInputFocused(): boolean {
const tag = document.activeElement?.tagName
return tag === 'INPUT' || tag === 'TEXTAREA'
const active = document.activeElement
if (!(active instanceof HTMLElement)) return false
if (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') return true
return active.isContentEditable || active.closest('[contenteditable="true"]') !== null
}
function isCommandOrCtrlOnly(e: KeyboardEvent): boolean {
@@ -119,7 +121,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

@@ -2,9 +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; code?: string } = {},
) {
const event = new KeyboardEvent('keydown', {
key,
code: mods.code,
altKey: mods.altKey ?? false,
metaKey: mods.metaKey ?? false,
ctrlKey: mods.ctrlKey ?? false,
@@ -12,7 +24,7 @@ function fireKey(key: string, mods: { altKey?: boolean; metaKey?: boolean; ctrlK
bubbles: true,
cancelable: true,
})
window.dispatchEvent(event)
target.dispatchEvent(event)
}
function makeActions() {
@@ -130,6 +142,14 @@ describe('useAppKeyboard', () => {
try { fn() } finally { document.body.removeChild(input) }
}
function withFocusedContentEditable(fn: (editable: HTMLDivElement) => void) {
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
document.body.appendChild(editable)
editable.focus()
try { fn(editable) } finally { document.body.removeChild(editable) }
}
it('Cmd+Backspace does not delete note when text input is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
@@ -139,6 +159,15 @@ describe('useAppKeyboard', () => {
})
})
it('Cmd+Backspace does not delete note when contenteditable is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
withFocusedContentEditable((editable) => {
fireKeyOnTarget(editable, 'Backspace', { metaKey: true })
expect(actions.onDeleteNote).not.toHaveBeenCalled()
})
})
it('Cmd+Backspace deletes note when no text input is focused', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
@@ -201,6 +230,25 @@ describe('useAppKeyboard', () => {
})
})
it('Cmd+Shift+L works when editor stops propagation', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
withFocusedContentEditable((editable) => {
editable.addEventListener('keydown', (event) => event.stopPropagation())
fireKeyOnTarget(editable, 'l', { metaKey: true, shiftKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
})
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

@@ -14,7 +14,7 @@ export function useAppKeyboard(actions: KeyboardActions) {
onKeyDown(event)
}
window.addEventListener('keydown', handleWindowKeyDown)
return () => window.removeEventListener('keydown', handleWindowKeyDown)
window.addEventListener('keydown', handleWindowKeyDown, true)
return () => window.removeEventListener('keydown', handleWindowKeyDown, true)
}, [])
}

View File

@@ -641,4 +641,3 @@ describe('useNoteActions hook', () => {
})
})
})

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useNoteActions, type NoteActionsConfig } from './useNoteActions'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(() => false),
addMockEntry: vi.fn(),
updateMockContent: vi.fn(),
trackMockChange: vi.fn(),
mockInvoke: vi.fn().mockResolvedValue(''),
}))
vi.mock('./mockFrontmatterHelpers', () => ({
updateMockFrontmatter: vi.fn().mockReturnValue('---\nupdated: true\n---\n'),
deleteMockFrontmatterProperty: vi.fn().mockReturnValue('---\n---\n'),
}))
function makeConfig(onFrontmatterPersisted: () => void): NoteActionsConfig {
return {
addEntry: vi.fn(),
removeEntry: vi.fn(),
entries: [],
setToastMessage: vi.fn(),
updateEntry: vi.fn(),
vaultPath: '/test/vault',
onFrontmatterPersisted,
}
}
describe('useNoteActions frontmatter persistence', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it.each([
{
label: 'update',
run: async (result: ReturnType<typeof renderHook<typeof useNoteActions>>['result']) => {
await result.current.handleUpdateFrontmatter('/vault/note.md', '_list_properties_display', ['Owner', 'Status'])
},
},
{
label: 'delete',
run: async (result: ReturnType<typeof renderHook<typeof useNoteActions>>['result']) => {
await result.current.handleDeleteProperty('/vault/note.md', 'status')
},
},
])('notifies after a frontmatter $label completes', async ({ run }) => {
const onFrontmatterPersisted = vi.fn()
const { result } = renderHook(() => useNoteActions(makeConfig(onFrontmatterPersisted)))
await act(async () => {
await run(result)
})
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
})
})

View File

@@ -27,6 +27,8 @@ export interface NoteActionsConfig {
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
/** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */
onFrontmatterContentChanged?: (path: string, content: string) => void
/** Called after a frontmatter mutation is fully persisted, including follow-up renames. */
onFrontmatterPersisted?: () => void
}
function isTitleKey(key: string): boolean {
@@ -44,6 +46,12 @@ interface TitleRenameDeps {
updateTabContent: (path: string, content: string) => void
}
function applyFrontmatterCallbacks(config: NoteActionsConfig, path: string, newContent: string | undefined): boolean {
if (!newContent) return false
config.onFrontmatterContentChanged?.(path, newContent)
return true
}
async function renameAfterTitleChange(path: string, newTitle: string, deps: TitleRenameDeps): Promise<void> {
const oldTitle = deps.tabsRef.current.find(t => t.entry.path === path)?.entry.title
const result = await performRename(path, newTitle, deps.vaultPath, oldTitle)
@@ -71,6 +79,20 @@ function navigateWikilink(entries: VaultEntry[], target: string, selectNote: (e:
else console.warn(`Navigation target not found: ${target}`)
}
async function maybeRenameAfterFrontmatterUpdate(
path: string,
key: string,
value: FrontmatterValue,
deps: TitleRenameDeps,
): Promise<void> {
if (!shouldRenameOnTitleUpdate(key, value)) return
try {
await renameAfterTitleChange(path, value, deps)
} catch (err) {
console.error('Failed to rename note after title change:', err)
}
}
export function useNoteActions(config: NoteActionsConfig) {
const { entries, setToastMessage, updateEntry } = config
const tabMgmt = useTabManagement()
@@ -108,25 +130,22 @@ export function useNoteActions(config: NoteActionsConfig) {
createTypeEntrySilent: creation.createTypeEntrySilent,
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('update', path, key, value, options)
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
if (shouldRenameOnTitleUpdate(key, value)) {
try {
await renameAfterTitleChange(path, value, {
vaultPath: config.vaultPath, tabsRef: rename.tabsRef, replaceEntry: config.replaceEntry,
setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent,
})
} catch (err) {
console.error('Failed to rename note after title change:', err)
}
}
if (!applyFrontmatterCallbacks(config, path, newContent)) return
await maybeRenameAfterFrontmatterUpdate(path, key, value, {
vaultPath: config.vaultPath, tabsRef: rename.tabsRef, replaceEntry: config.replaceEntry,
setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent,
})
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
if (!applyFrontmatterCallbacks(config, path, newContent)) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
const newContent = await runFrontmatterOp('update', path, key, value)
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
if (!applyFrontmatterCallbacks(config, path, newContent)) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleRenameNote: rename.handleRenameNote,
}

View File

@@ -8,9 +8,10 @@ test.describe('AI panel shortcut', () => {
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible({ timeout: 5_000 })
})
test('Cmd/Ctrl+Shift+L opens the AI panel', async ({ page }) => {
test('Cmd+Shift+L opens the AI panel from the editor', async ({ page }) => {
await page.locator('.app__note-list .cursor-pointer').first().click()
await sendShortcut(page, 'L', ['Control', 'Shift'])
await page.locator('.bn-editor').click()
await sendShortcut(page, 'L', ['Meta', 'Shift'])
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3_000 })
await expect(page.getByTitle('Close AI panel')).toBeVisible()
})