Compare commits
8 Commits
v0.2026040
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fabc2a1d7 | ||
|
|
2f32a1781a | ||
|
|
94ce91457d | ||
|
|
717d97f6f0 | ||
|
|
727a1b95d0 | ||
|
|
000d89df50 | ||
|
|
b9879b15c9 | ||
|
|
32a30b40b9 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.68
|
||||
AVERAGE_THRESHOLD=9.32
|
||||
AVERAGE_THRESHOLD=9.33
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -10,18 +10,20 @@ describe('NoteTitleIcon', () => {
|
||||
})
|
||||
|
||||
it('renders a Phosphor icon when the name is recognized', () => {
|
||||
render(<NoteTitleIcon icon="cooking pot" testId="note-title-icon" />)
|
||||
const { container } = render(<NoteTitleIcon icon="cooking pot" testId="note-title-icon" className="mr-1" />)
|
||||
|
||||
const icon = screen.getByTestId('note-title-icon')
|
||||
expect(icon.tagName.toLowerCase()).toBe('svg')
|
||||
expect(container.querySelector('span.inline-flex.mr-1')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders an image when the icon is an http url', () => {
|
||||
render(<NoteTitleIcon icon="https://example.com/favicon.png" testId="note-title-icon" />)
|
||||
const { container } = render(<NoteTitleIcon icon="https://example.com/favicon.png" testId="note-title-icon" className="mr-1" />)
|
||||
|
||||
const icon = screen.getByTestId('note-title-icon')
|
||||
expect(icon.tagName.toLowerCase()).toBe('img')
|
||||
expect(icon).toHaveAttribute('src', 'https://example.com/favicon.png')
|
||||
expect(container.querySelector('span.inline-flex.mr-1')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing for an unrecognized icon value', () => {
|
||||
|
||||
@@ -9,6 +9,21 @@ interface NoteTitleIconProps {
|
||||
testId?: string
|
||||
}
|
||||
|
||||
function IconWrapper({
|
||||
children,
|
||||
className,
|
||||
size,
|
||||
}: Pick<NoteTitleIconProps, 'className' | 'size'> & { children: React.ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center justify-center align-middle', className)}
|
||||
style={{ width: size, height: size, lineHeight: 1 }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteTitleIcon({ icon, size = 14, className, color, testId }: NoteTitleIconProps) {
|
||||
const resolved = resolveNoteIcon(icon)
|
||||
|
||||
@@ -16,39 +31,40 @@ export function NoteTitleIcon({ icon, size = 14, className, color, testId }: Not
|
||||
|
||||
if (resolved.kind === 'emoji') {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center justify-center', className)}
|
||||
style={{ fontSize: size, lineHeight: 1 }}
|
||||
data-testid={testId}
|
||||
>
|
||||
{resolved.value}
|
||||
</span>
|
||||
<IconWrapper className={className} size={size}>
|
||||
<span style={{ fontSize: size, lineHeight: 1 }} data-testid={testId}>
|
||||
{resolved.value}
|
||||
</span>
|
||||
</IconWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
if (resolved.kind === 'image') {
|
||||
return (
|
||||
<img
|
||||
src={resolved.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={cn('shrink-0 rounded-sm object-cover', className)}
|
||||
style={{ width: size, height: size }}
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = 'none'
|
||||
}}
|
||||
data-testid={testId}
|
||||
/>
|
||||
<IconWrapper className={className} size={size}>
|
||||
<img
|
||||
src={resolved.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="block h-full w-full rounded-sm object-cover"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = 'none'
|
||||
}}
|
||||
data-testid={testId}
|
||||
/>
|
||||
</IconWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<resolved.Icon
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn('shrink-0', className)}
|
||||
style={color ? { color } : undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
<IconWrapper className={className} size={size}>
|
||||
<resolved.Icon
|
||||
width={size}
|
||||
height={size}
|
||||
className="block"
|
||||
style={color ? { color } : undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
</IconWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,26 @@ describe('deriveEditorContentState', () => {
|
||||
content: '---\ntitle: Legacy Project\n---\nBody without a heading',
|
||||
})
|
||||
|
||||
expect(state.hasH1).toBe(false)
|
||||
expect(state.showTitleSection).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the legacy title section when a frontmatter title drives the display title', () => {
|
||||
const state = deriveState({
|
||||
entry: baseEntry,
|
||||
content: '---\ntitle: Spring 2026\nstatus: Active\n---\n## Goals',
|
||||
})
|
||||
|
||||
expect(state.hasH1).toBe(false)
|
||||
expect(state.showTitleSection).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the title section when the document title still comes from the filename', () => {
|
||||
const state = deriveState({
|
||||
entry: baseEntry,
|
||||
content: '---\nstatus: Active\n---\nBody without a heading',
|
||||
})
|
||||
|
||||
expect(state.hasH1).toBe(false)
|
||||
expect(state.showTitleSection).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { NoteStatus, VaultEntry } from '../../types'
|
||||
import { extractH1TitleFromContent } from '../../utils/noteTitle'
|
||||
import { contentDefinesDisplayTitle, extractH1TitleFromContent } from '../../utils/noteTitle'
|
||||
import { countWords } from '../../utils/wikilinks'
|
||||
|
||||
export interface EditorContentTab {
|
||||
@@ -14,6 +14,19 @@ interface EditorContentStateInput {
|
||||
activeStatus: NoteStatus
|
||||
}
|
||||
|
||||
interface TitleSectionState {
|
||||
hasDisplayTitle: boolean
|
||||
hasH1: boolean
|
||||
}
|
||||
|
||||
interface VisibilityState {
|
||||
effectiveRawMode: boolean
|
||||
isDeletedPreview: boolean
|
||||
isNonMarkdownText: boolean
|
||||
showEditor: boolean
|
||||
showTitleSection: boolean
|
||||
}
|
||||
|
||||
export interface EditorContentState {
|
||||
freshEntry: VaultEntry | undefined
|
||||
isArchived: boolean
|
||||
@@ -36,10 +49,53 @@ function contentHasTopLevelH1(activeTab: EditorContentTab | null): boolean {
|
||||
return activeTab ? extractH1TitleFromContent(activeTab.content) !== null : false
|
||||
}
|
||||
|
||||
function contentDefinesTitle(activeTab: EditorContentTab | null): boolean {
|
||||
return activeTab ? contentDefinesDisplayTitle(activeTab.content) : false
|
||||
}
|
||||
|
||||
function resolveHasH1(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): boolean {
|
||||
return contentHasTopLevelH1(activeTab) || freshEntry?.hasH1 === true || activeTab?.entry.hasH1 === true
|
||||
}
|
||||
|
||||
function resolveHasDisplayTitle(activeTab: EditorContentTab | null, hasH1: boolean): boolean {
|
||||
return hasH1 || contentDefinesTitle(activeTab)
|
||||
}
|
||||
|
||||
function deriveTitleSectionState(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): TitleSectionState {
|
||||
const hasH1 = resolveHasH1(activeTab, freshEntry)
|
||||
return {
|
||||
hasDisplayTitle: resolveHasDisplayTitle(activeTab, hasH1),
|
||||
hasH1,
|
||||
}
|
||||
}
|
||||
|
||||
function deriveVisibilityState(input: {
|
||||
activeStatus: NoteStatus
|
||||
activeTab: EditorContentTab | null
|
||||
freshEntry: VaultEntry | undefined
|
||||
hasDisplayTitle: boolean
|
||||
rawMode: boolean
|
||||
}): VisibilityState {
|
||||
const {
|
||||
activeStatus,
|
||||
activeTab,
|
||||
freshEntry,
|
||||
hasDisplayTitle,
|
||||
rawMode,
|
||||
} = input
|
||||
const isDeletedPreview = !!activeTab && !freshEntry
|
||||
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
|
||||
const effectiveRawMode = rawMode || isNonMarkdownText
|
||||
|
||||
return {
|
||||
isDeletedPreview,
|
||||
isNonMarkdownText,
|
||||
effectiveRawMode,
|
||||
showEditor: !effectiveRawMode,
|
||||
showTitleSection: !isDeletedPreview && !hasDisplayTitle && !isUnsavedUntitledDraft(activeTab, activeStatus),
|
||||
}
|
||||
}
|
||||
|
||||
function isUnsavedUntitledDraft(activeTab: EditorContentTab | null, activeStatus: NoteStatus): boolean {
|
||||
if (!activeTab) return false
|
||||
if (!activeTab.entry.filename.startsWith('untitled-')) return false
|
||||
@@ -53,22 +109,20 @@ export function deriveEditorContentState({
|
||||
activeStatus,
|
||||
}: EditorContentStateInput): EditorContentState {
|
||||
const freshEntry = findFreshEntry(activeTab, entries)
|
||||
const isDeletedPreview = !!activeTab && !freshEntry
|
||||
const hasH1 = resolveHasH1(activeTab, freshEntry)
|
||||
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
|
||||
const effectiveRawMode = rawMode || isNonMarkdownText
|
||||
const showEditor = !effectiveRawMode
|
||||
const showTitleSection = !isDeletedPreview && !hasH1 && !isUnsavedUntitledDraft(activeTab, activeStatus)
|
||||
const titleState = deriveTitleSectionState(activeTab, freshEntry)
|
||||
const visibilityState = deriveVisibilityState({
|
||||
activeStatus,
|
||||
activeTab,
|
||||
freshEntry,
|
||||
hasDisplayTitle: titleState.hasDisplayTitle,
|
||||
rawMode,
|
||||
})
|
||||
|
||||
return {
|
||||
freshEntry,
|
||||
isArchived: freshEntry?.archived ?? activeTab?.entry.archived ?? false,
|
||||
hasH1,
|
||||
isDeletedPreview,
|
||||
isNonMarkdownText,
|
||||
effectiveRawMode,
|
||||
showEditor,
|
||||
showTitleSection,
|
||||
hasH1: titleState.hasH1,
|
||||
...visibilityState,
|
||||
path: activeTab?.entry.path ?? '',
|
||||
wordCount: activeTab ? countWords(activeTab.content) : 0,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -36,11 +36,11 @@ export function useEditorSaveWithLinks(config: {
|
||||
const filename = path.split('/').pop() ?? path
|
||||
const fmPatch = {
|
||||
...frontmatterPatch,
|
||||
...deriveDisplayTitleState(
|
||||
...deriveDisplayTitleState({
|
||||
content,
|
||||
filename,
|
||||
typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
|
||||
),
|
||||
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
|
||||
}),
|
||||
}
|
||||
const fmKey = JSON.stringify(fmPatch)
|
||||
if (fmKey !== prevFmKeyRef.current) {
|
||||
|
||||
@@ -641,4 +641,3 @@ describe('useNoteActions hook', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
58
src/hooks/useNoteActions.persistence.test.ts
Normal file
58
src/hooks/useNoteActions.persistence.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deriveDisplayTitleState, extractH1TitleFromContent, filenameStemToTitle } from './noteTitle'
|
||||
import {
|
||||
contentDefinesDisplayTitle,
|
||||
deriveDisplayTitleState,
|
||||
extractFrontmatterTitleFromContent,
|
||||
extractH1TitleFromContent,
|
||||
filenameStemToTitle,
|
||||
} from './noteTitle'
|
||||
|
||||
describe('filenameStemToTitle', () => {
|
||||
it('converts kebab-case filenames into title case', () => {
|
||||
@@ -23,10 +29,32 @@ describe('extractH1TitleFromContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFrontmatterTitleFromContent', () => {
|
||||
it('extracts the frontmatter title when present', () => {
|
||||
const content = '---\ntitle: Legacy Title\nstatus: Active\n---\n## Body'
|
||||
expect(extractFrontmatterTitleFromContent(content)).toBe('Legacy Title')
|
||||
})
|
||||
|
||||
it('returns null when the frontmatter title is missing', () => {
|
||||
expect(extractFrontmatterTitleFromContent('---\nstatus: Active\n---\n## Body')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('contentDefinesDisplayTitle', () => {
|
||||
it('returns true when the document title comes from frontmatter', () => {
|
||||
const content = '---\ntitle: Spring 2026\n---\n## Goals'
|
||||
expect(contentDefinesDisplayTitle(content)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when title still comes from the filename', () => {
|
||||
expect(contentDefinesDisplayTitle('Body only')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveDisplayTitleState', () => {
|
||||
it('prefers H1 over frontmatter title and filename', () => {
|
||||
const content = '---\ntitle: Legacy Title\n---\n# Updated Title\n\nBody'
|
||||
expect(deriveDisplayTitleState(content, 'legacy-title.md', 'Legacy Title')).toEqual({
|
||||
expect(deriveDisplayTitleState({ content, filename: 'legacy-title.md', frontmatterTitle: 'Legacy Title' })).toEqual({
|
||||
title: 'Updated Title',
|
||||
hasH1: true,
|
||||
})
|
||||
@@ -34,14 +62,22 @@ describe('deriveDisplayTitleState', () => {
|
||||
|
||||
it('falls back to frontmatter title when no H1 is present', () => {
|
||||
const content = '---\ntitle: Legacy Title\n---\nBody'
|
||||
expect(deriveDisplayTitleState(content, 'legacy-title.md', 'Legacy Title')).toEqual({
|
||||
expect(deriveDisplayTitleState({ content, filename: 'legacy-title.md', frontmatterTitle: 'Legacy Title' })).toEqual({
|
||||
title: 'Legacy Title',
|
||||
hasH1: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the frontmatter title from content when no explicit title is passed', () => {
|
||||
const content = '---\ntitle: Spring 2026\n---\n## Goals'
|
||||
expect(deriveDisplayTitleState({ content, filename: 'spring-2026.md' })).toEqual({
|
||||
title: 'Spring 2026',
|
||||
hasH1: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to filename title when there is no H1 or frontmatter title', () => {
|
||||
expect(deriveDisplayTitleState('Body only', 'renamed-note.md')).toEqual({
|
||||
expect(deriveDisplayTitleState({ content: 'Body only', filename: 'renamed-note.md' })).toEqual({
|
||||
title: 'Renamed Note',
|
||||
hasH1: false,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import { parseFrontmatter } from './frontmatter'
|
||||
import { splitFrontmatter } from './wikilinks'
|
||||
|
||||
interface ResolvedContentTitle {
|
||||
source: 'h1' | 'frontmatter'
|
||||
title: string
|
||||
}
|
||||
|
||||
interface DisplayTitleInput {
|
||||
content: string
|
||||
filename: string
|
||||
frontmatterTitle?: string | null
|
||||
}
|
||||
|
||||
interface DisplayTitleState {
|
||||
title: string
|
||||
hasH1: boolean
|
||||
}
|
||||
|
||||
function replaceWikilinkAliases(text: string): string {
|
||||
return text.replace(/\[\[[^|\]]+\|([^\]]+)\]\]/g, '$1')
|
||||
}
|
||||
@@ -49,20 +66,46 @@ export function extractH1TitleFromContent(content: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function deriveDisplayTitleState(
|
||||
content: string,
|
||||
filename: string,
|
||||
frontmatterTitle?: string | null,
|
||||
): { title: string, hasH1: boolean } {
|
||||
export function extractFrontmatterTitleFromContent(content: string): string | null {
|
||||
const title = parseFrontmatter(content).title
|
||||
if (typeof title !== 'string') return null
|
||||
const trimmed = title.trim()
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
function resolveContentTitle(content: string, frontmatterTitle?: string | null): ResolvedContentTitle | null {
|
||||
const h1Title = extractH1TitleFromContent(content)
|
||||
if (h1Title) {
|
||||
return { title: h1Title, hasH1: true }
|
||||
return { title: h1Title, source: 'h1' }
|
||||
}
|
||||
|
||||
const trimmedFrontmatterTitle = frontmatterTitle?.trim()
|
||||
if (trimmedFrontmatterTitle) {
|
||||
return { title: trimmedFrontmatterTitle, hasH1: false }
|
||||
const resolvedFrontmatterTitle = frontmatterTitle?.trim() || extractFrontmatterTitleFromContent(content)
|
||||
if (resolvedFrontmatterTitle) {
|
||||
return { title: resolvedFrontmatterTitle, source: 'frontmatter' }
|
||||
}
|
||||
|
||||
return { title: filenameStemToTitle(filename), hasH1: false }
|
||||
return null
|
||||
}
|
||||
|
||||
export function contentDefinesDisplayTitle(content: string): boolean {
|
||||
return resolveContentTitle(content) !== null
|
||||
}
|
||||
|
||||
export function deriveDisplayTitleState({
|
||||
content,
|
||||
filename,
|
||||
frontmatterTitle,
|
||||
}: DisplayTitleInput): DisplayTitleState {
|
||||
const resolvedTitle = resolveContentTitle(content, frontmatterTitle)
|
||||
if (resolvedTitle) {
|
||||
return {
|
||||
title: resolvedTitle.title,
|
||||
hasH1: resolvedTitle.source === 'h1',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: filenameStemToTitle(filename),
|
||||
hasH1: false,
|
||||
}
|
||||
}
|
||||
|
||||
10
tests/fixtures/test-vault/quarter/spring-2026.md
vendored
Normal file
10
tests/fixtures/test-vault/quarter/spring-2026.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Spring 2026
|
||||
Is A: Quarter
|
||||
Status: Active
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- [[Alpha Project]]
|
||||
- [[Note B]]
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -69,12 +69,17 @@ test('creating an untitled draft hides the legacy title section in the editor',
|
||||
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('@smoke older notes with an H1 do not render the legacy title section', async ({ page }) => {
|
||||
test('@smoke older notes with a document title do not render the legacy title section', async ({ page }) => {
|
||||
await openNote(page, 'Alpha Project')
|
||||
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('title-field-input')).toHaveCount(0)
|
||||
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
|
||||
|
||||
await openNote(page, 'Spring 2026')
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('title-field-input')).toHaveCount(0)
|
||||
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete', async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user