Compare commits
4 Commits
v0.2026040
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f32a1781a | ||
|
|
94ce91457d | ||
|
|
717d97f6f0 | ||
|
|
727a1b95d0 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.68
|
||||
AVERAGE_THRESHOLD=9.32
|
||||
AVERAGE_THRESHOLD=9.33
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -121,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,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,
|
||||
@@ -237,6 +241,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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user