diff --git a/src/components/SettingsControls.tsx b/src/components/SettingsControls.tsx index 0078baf7..cdc29a25 100644 --- a/src/components/SettingsControls.tsx +++ b/src/components/SettingsControls.tsx @@ -132,7 +132,7 @@ export function SelectControl({ > - + {options.map((option) => ( {option.label} diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 6b8df32d..daf90660 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, within } from '@testing-library/react' +import { act, fireEvent, render, screen, within } from '@testing-library/react' import { SettingsPanel } from './SettingsPanel' import type { Settings } from '../types' import { THEME_MODE_STORAGE_KEY } from '../lib/themeMode' @@ -855,6 +855,29 @@ describe('SettingsPanel', () => { expect(closeButton).toHaveFocus() }) + it('does not trap focus away from a portaled settings dropdown', () => { + render( + + ) + + act(() => { + fireEvent.pointerDown(screen.getByTestId('settings-default-ai-agent'), { button: 0, pointerType: 'mouse' }) + }) + const option = screen.getByRole('option', { name: /Codex/i }) + act(() => { + option.focus() + }) + + const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + act(() => { + document.dispatchEvent(event) + }) + + expect(event.defaultPrevented).toBe(false) + expect(screen.getByTitle('Close settings')).not.toHaveFocus() + expect(screen.getByTestId('settings-save')).not.toHaveFocus() + }) + it('copies the MCP config from the AI Agents section', () => { const onCopyMcpConfig = vi.fn() render( diff --git a/src/components/TldrawWhiteboard.test.tsx b/src/components/TldrawWhiteboard.test.tsx index 0d7bb5cb..d452aa23 100644 --- a/src/components/TldrawWhiteboard.test.tsx +++ b/src/components/TldrawWhiteboard.test.tsx @@ -26,6 +26,17 @@ const tldrawMock = vi.hoisted(() => ({ Tldraw: vi.fn(), })) +const tldrawStoreMock = vi.hoisted(() => ({ + createTLStore: vi.fn(() => ({ + document: { records: {} }, + listen: vi.fn(() => vi.fn()), + })), + getSnapshot: vi.fn((store: { document?: unknown }) => ({ document: store.document ?? { records: {} } })), + loadSnapshot: vi.fn((store: { document?: unknown }, snapshot: unknown) => { + store.document = snapshot + }), +})) + const assetImportMock = vi.hoisted(() => ({ getAssetUrlsByImport: vi.fn((formatAssetUrl: (assetUrl?: string) => string) => ({ embedIcons: {}, @@ -68,15 +79,13 @@ vi.mock('tldraw', async () => { } }, Tldraw: tldrawMock.Tldraw, - createTLStore: vi.fn(() => ({ - listen: vi.fn(() => vi.fn()), - })), + createTLStore: tldrawStoreMock.createTLStore, defaultUserPreferences: { colorScheme: 'light', locale: 'en', }, - getSnapshot: vi.fn(() => ({ document: {} })), - loadSnapshot: vi.fn(), + getSnapshot: tldrawStoreMock.getSnapshot, + loadSnapshot: tldrawStoreMock.loadSnapshot, useTldrawUser: vi.fn(({ userPreferences }: { userPreferences: { colorScheme: string } }) => ({ setUserPreferences: vi.fn(), userPreferences: { @@ -236,4 +245,33 @@ describe('TldrawWhiteboard', () => { cleanup() expect(() => editor.textMeasure.measureElementTextNodeSpans(measuredTextElement())).toThrow('top') }) + + it('resets the drawing store when switching to a blank board snapshot', () => { + const boardASnapshot = { records: { shape: 'from-board-a' } } + const { rerender } = render( + + ) + + expect(tldrawStoreMock.loadSnapshot).toHaveBeenLastCalledWith(expect.any(Object), boardASnapshot) + + rerender( + + ) + + expect(tldrawStoreMock.loadSnapshot).toHaveBeenLastCalledWith(expect.any(Object), { records: {} }) + }) }) diff --git a/src/components/TldrawWhiteboard.tsx b/src/components/TldrawWhiteboard.tsx index c5778fdf..25d98a19 100644 --- a/src/components/TldrawWhiteboard.tsx +++ b/src/components/TldrawWhiteboard.tsx @@ -111,6 +111,11 @@ function parseSnapshot(source: string): TLStoreSnapshot | null { } } +function createBoardStore(boardId: string) { + void boardId + return createTLStore() +} + function serializeSnapshot(snapshot: TLStoreSnapshot): string { return `${JSON.stringify(snapshot, null, 2)}\n` } @@ -364,9 +369,10 @@ export function TldrawWhiteboard({ onSnapshotChange, onSizeChange, }: TldrawWhiteboardProps) { - const store = useMemo(() => createTLStore(), []) + const store = useMemo(() => createBoardStore(boardId), [boardId]) const boardRef = useRef(null) const savedSnapshotRef = useRef(null) + const savedBoardIdRef = useRef(null) const onSnapshotChangeRef = useRef(onSnapshotChange) const persistedSize = useMemo(() => normalizeSize({ height, width }), [height, width]) const [resizingSize, setResizingSize] = useState(null) @@ -384,12 +390,13 @@ export function TldrawWhiteboard({ }, [onSnapshotChange]) useEffect(() => { - if (snapshot === savedSnapshotRef.current) return + if (boardId === savedBoardIdRef.current && snapshot === savedSnapshotRef.current) return const parsed = parseSnapshot(snapshot) if (parsed) { try { loadSnapshot(store, parsed) + savedBoardIdRef.current = boardId savedSnapshotRef.current = snapshot return } catch { @@ -397,8 +404,12 @@ export function TldrawWhiteboard({ } } - savedSnapshotRef.current = serializeSnapshot(getSnapshot(store).document) - }, [snapshot, store]) + const emptyStore = createTLStore() + const emptySnapshot = getSnapshot(emptyStore).document + loadSnapshot(store, emptySnapshot) + savedBoardIdRef.current = boardId + savedSnapshotRef.current = serializeSnapshot(emptySnapshot) + }, [boardId, snapshot, store]) useEffect(() => { let timeoutId: number | null = null @@ -408,6 +419,7 @@ export function TldrawWhiteboard({ const nextSnapshot = serializeSnapshot(getSnapshot(store).document) if (nextSnapshot === savedSnapshotRef.current) return + savedBoardIdRef.current = boardId savedSnapshotRef.current = nextSnapshot onSnapshotChangeRef.current(nextSnapshot) } @@ -422,7 +434,7 @@ export function TldrawWhiteboard({ cleanup() if (timeoutId !== null) window.clearTimeout(timeoutId) } - }, [store]) + }, [boardId, store]) const startResize = (mode: ResizeMode) => (event: ReactPointerEvent) => { event.preventDefault() @@ -472,6 +484,7 @@ export function TldrawWhiteboard({ { expect(onClose).toHaveBeenCalled() }) + it('flushes the latest template edit before closing', () => { + vi.useFakeTimers() + try { + renderPopover() + fireEvent.change(screen.getByTestId('template-textarea'), { target: { value: '## Immediate' } }) + + fireEvent.click(screen.getByText('Done')) + + expect(onChangeTemplate).toHaveBeenCalledWith('## Immediate') + expect(onClose).toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('renders curated color options without the teal near-duplicate', () => { renderPopover() @@ -208,6 +223,20 @@ describe('TypeCustomizePopover', () => { } }) + it('flushes a pending template edit on unmount', () => { + vi.useFakeTimers() + try { + const { unmount } = renderPopover() + fireEvent.change(screen.getByTestId('template-textarea'), { target: { value: '## Unmounted' } }) + + unmount() + + expect(onChangeTemplate).toHaveBeenCalledWith('## Unmounted') + } finally { + vi.useRealTimers() + } + }) + it('treats null template as empty string', () => { renderPopover({ currentTemplate: null }) const textarea = screen.getByTestId('template-textarea') as HTMLTextAreaElement diff --git a/src/components/TypeCustomizePopover.tsx b/src/components/TypeCustomizePopover.tsx index 049010de..e7026717 100644 --- a/src/components/TypeCustomizePopover.tsx +++ b/src/components/TypeCustomizePopover.tsx @@ -52,18 +52,37 @@ interface TemplateSectionProps { const ICON_PICKER_ICON_SIZE = 18 const ICON_PICKER_ICON_CLASS_NAME = 'size-[18px]' -/** Debounce a callback by `delay` ms. Returns a stable ref-based wrapper. */ -function useDebouncedCallback(fn: (v: string) => void, delay: number): (v: string) => void { +interface DebouncedCallback { + flush: () => void + run: (value: string) => void +} + +/** Debounce a callback by `delay` ms. Pending work is flushed on unmount. */ +function useDebouncedCallback(fn: (v: string) => void, delay: number): DebouncedCallback { const timerRef = useRef | undefined>(undefined) + const pendingValueRef = useRef(null) const fnRef = useRef(fn) useEffect(() => { fnRef.current = fn }) - useEffect(() => () => { clearTimeout(timerRef.current) }, []) - - return useCallback((v: string) => { + const flush = useCallback(() => { clearTimeout(timerRef.current) - timerRef.current = setTimeout(() => fnRef.current(v), delay) - }, [delay]) + timerRef.current = undefined + if (pendingValueRef.current === null) return + + const value = pendingValueRef.current + pendingValueRef.current = null + fnRef.current(value) + }, []) + + const run = useCallback((value: string) => { + clearTimeout(timerRef.current) + pendingValueRef.current = value + timerRef.current = setTimeout(flush, delay) + }, [delay, flush]) + + useEffect(() => () => { flush() }, [flush]) + + return useMemo(() => ({ flush, run }), [flush, run]) } function ColorSection({ selectedColor, locale, onSelectColor }: ColorSectionProps) { @@ -188,6 +207,7 @@ export function TypeCustomizePopover({ const [templateText, setTemplateText] = useState(currentTemplate ?? '') const filteredIcons = useMemo(() => filterIcons(ICON_OPTIONS, search), [search]) + const debouncedSaveTemplate = useDebouncedCallback(onChangeTemplate, 500) const handleColorClick = (key: string) => { setSelectedColor(key) @@ -199,11 +219,14 @@ export function TypeCustomizePopover({ onChangeIcon(name) } - const debouncedSaveTemplate = useDebouncedCallback(onChangeTemplate, 500) - const handleTemplateChange = (value: string) => { setTemplateText(value) - debouncedSaveTemplate(value) + debouncedSaveTemplate.run(value) + } + + const handleDone = () => { + debouncedSaveTemplate.flush() + onClose() } return ( @@ -228,7 +251,7 @@ export function TypeCustomizePopover({ {showTemplate && ( )} - {showDone && } + {showDone && } ) } diff --git a/src/components/inlineWikilinkSuggestions.test.ts b/src/components/inlineWikilinkSuggestions.test.ts new file mode 100644 index 00000000..9ef64238 --- /dev/null +++ b/src/components/inlineWikilinkSuggestions.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { makeEntry } from '../test-utils/noteListTestUtils' +import { buildInlineWikilinkSuggestions } from './inlineWikilinkSuggestions' + +describe('buildInlineWikilinkSuggestions', () => { + it('deduplicates and disambiguates empty-query suggestions', () => { + const suggestions = buildInlineWikilinkSuggestions([ + makeEntry({ path: '/vault/projects/alpha.md', title: 'Alpha' }), + makeEntry({ path: '/vault/archive/alpha.md', title: 'Alpha' }), + makeEntry({ path: '/vault/projects/alpha.md', title: 'Alpha Duplicate' }), + ], '') + + expect(suggestions.map((suggestion) => suggestion.title)).toEqual([ + 'Alpha (archive)', + 'Alpha (projects)', + ]) + }) +}) diff --git a/src/components/inlineWikilinkSuggestions.ts b/src/components/inlineWikilinkSuggestions.ts index 77865651..55a214ed 100644 --- a/src/components/inlineWikilinkSuggestions.ts +++ b/src/components/inlineWikilinkSuggestions.ts @@ -55,7 +55,7 @@ function matchSingleCharacterQuery( } function buildTopSuggestions(items: SuggestionItem[]): InlineWikilinkSuggestion[] { - return [...items] + return disambiguateTitles(deduplicateByPath([...items])) .sort((left, right) => left.title.localeCompare(right.title)) .slice(0, MAX_RESULTS) .map(toSuggestion) diff --git a/src/components/propertyDropdownUtils.test.ts b/src/components/propertyDropdownUtils.test.ts index 00195296..c8b1fa76 100644 --- a/src/components/propertyDropdownUtils.test.ts +++ b/src/components/propertyDropdownUtils.test.ts @@ -25,4 +25,8 @@ describe('propertyDropdownUtils', () => { expect(getAnchoredDropdownLeft(100, 208, 800)).toBe(8) expect(getAnchoredDropdownLeft(900, 208, 800)).toBe(584) }) + + it('keeps an over-wide dropdown at the visible left margin', () => { + expect(getAnchoredDropdownLeft(320, 400, 320, 8)).toBe(8) + }) }) diff --git a/src/components/propertyDropdownUtils.ts b/src/components/propertyDropdownUtils.ts index 7cfba9b0..84cbbd6d 100644 --- a/src/components/propertyDropdownUtils.ts +++ b/src/components/propertyDropdownUtils.ts @@ -11,6 +11,7 @@ export function getAnchoredDropdownLeft( const rightAlignedLeft = anchorRight - dropdownWidth const minLeft = margin const maxLeft = viewportWidth - dropdownWidth - margin + if (maxLeft < minLeft) return minLeft return Math.min(Math.max(rightAlignedLeft, minLeft), maxLeft) } diff --git a/src/components/richEditorTransformErrorRecoveryExtension.test.ts b/src/components/richEditorTransformErrorRecoveryExtension.test.ts index 809363bc..d32ab8a9 100644 --- a/src/components/richEditorTransformErrorRecoveryExtension.test.ts +++ b/src/components/richEditorTransformErrorRecoveryExtension.test.ts @@ -117,6 +117,24 @@ describe('installRichEditorTransformErrorRecovery', () => { secondUninstall() expect(view.dispatch).toBe(dispatch) }) + + it('restores the previous recoverDocument callback when a later install unmounts', () => { + const schemaError = new RangeError( + 'Invalid content for node blockContainer: ', + ) + const { currentDoc, view } = createView(schemaError) + const firstRecoverDocument = vi.fn() + const secondRecoverDocument = vi.fn() + + installRichEditorTransformErrorRecovery(view, { recoverDocument: firstRecoverDocument }) + const secondUninstall = installRichEditorTransformErrorRecovery(view, { recoverDocument: secondRecoverDocument }) + secondUninstall() + + view.dispatch({ before: currentDoc }) + + expect(firstRecoverDocument).toHaveBeenCalledTimes(1) + expect(secondRecoverDocument).not.toHaveBeenCalled() + }) }) describe('createRichEditorTransformErrorRecoveryExtension', () => { diff --git a/src/components/richEditorTransformErrorRecoveryExtension.ts b/src/components/richEditorTransformErrorRecoveryExtension.ts index 4d5c56b3..6a3c3954 100644 --- a/src/components/richEditorTransformErrorRecoveryExtension.ts +++ b/src/components/richEditorTransformErrorRecoveryExtension.ts @@ -6,6 +6,7 @@ const DISPATCH_RECOVERY_STATE_KEY = '__tolariaRichEditorTransformErrorRecovery' type RichEditorDispatch = (transaction: unknown) => unknown type RecoverEditorDocument = () => void +type RecoveryToken = symbol interface RichEditorDispatchView { dispatch: RichEditorDispatch @@ -18,7 +19,10 @@ interface RichEditorDispatchView { interface DispatchRecoveryState { originalDispatch: RichEditorDispatch - recoverDocument?: RecoverEditorDocument + recoverDocuments: Array<{ + recoverDocument: RecoverEditorDocument + token: RecoveryToken + }> refCount: number } @@ -90,10 +94,12 @@ function releaseRecoveryState( view: RichEditorDispatchView, recoveryState: DispatchRecoveryState, originalDispatch: RichEditorDispatch, + token: RecoveryToken, ): void { const state = Reflect.get(view, DISPATCH_RECOVERY_STATE_KEY) if (!isDispatchRecoveryState(state) || state.originalDispatch !== originalDispatch) return + state.recoverDocuments = state.recoverDocuments.filter((entry) => entry.token !== token) state.refCount -= 1 if (state.refCount > 0) return @@ -104,11 +110,16 @@ function releaseRecoveryState( function retainRecoveryState( view: RichEditorDispatchView, recoveryState: DispatchRecoveryState, + token: RecoveryToken, recoverDocument?: RecoverEditorDocument, ): () => void { recoveryState.refCount += 1 - if (recoverDocument) recoveryState.recoverDocument = recoverDocument - return () => releaseRecoveryState(view, recoveryState, recoveryState.originalDispatch) + if (recoverDocument) recoveryState.recoverDocuments.push({ recoverDocument, token }) + return () => releaseRecoveryState(view, recoveryState, recoveryState.originalDispatch, token) +} + +function activeRecoverDocument(recoveryState: DispatchRecoveryState): RecoverEditorDocument | undefined { + return recoveryState.recoverDocuments.at(-1)?.recoverDocument } function createRecoveringDispatch( @@ -122,7 +133,7 @@ function createRecoveringDispatch( if (!isRecoverableEditorTransformError(error)) throw error if (isInvalidContentTransactionError(error)) { - recoveryState.recoverDocument?.() + activeRecoverDocument(recoveryState)?.() } reportRecoveredEditorTransformError(recoveryReason(error, transaction, view), error) return undefined @@ -133,11 +144,12 @@ function createRecoveringDispatch( function installRecoveryState( view: RichEditorDispatchView, originalDispatch: RichEditorDispatch, + token: RecoveryToken, recoverDocument?: RecoverEditorDocument, ): DispatchRecoveryState { const recoveryState: DispatchRecoveryState = { originalDispatch, - recoverDocument, + recoverDocuments: recoverDocument ? [{ recoverDocument, token }] : [], refCount: 1, } @@ -164,15 +176,16 @@ export function installRichEditorTransformErrorRecovery( view: RichEditorDispatchView, options: InstallRecoveryOptions = {}, ): () => void { + const token = Symbol('rich-editor-transform-error-recovery') const currentState = Reflect.get(view, DISPATCH_RECOVERY_STATE_KEY) if (isDispatchRecoveryState(currentState)) { - return retainRecoveryState(view, currentState, options.recoverDocument) + return retainRecoveryState(view, currentState, token, options.recoverDocument) } const originalDispatch = view.dispatch - const recoveryState = installRecoveryState(view, originalDispatch, options.recoverDocument) + const recoveryState = installRecoveryState(view, originalDispatch, token, options.recoverDocument) - return () => releaseRecoveryState(view, recoveryState, originalDispatch) + return () => releaseRecoveryState(view, recoveryState, originalDispatch, token) } export const createRichEditorTransformErrorRecoveryExtension = createExtension(({ editor }) => ({ diff --git a/src/components/useSettingsPanelFocus.ts b/src/components/useSettingsPanelFocus.ts index 7ee40af9..04310093 100644 --- a/src/components/useSettingsPanelFocus.ts +++ b/src/components/useSettingsPanelFocus.ts @@ -19,7 +19,8 @@ const settingsBoundaryElement = (focusableElements: HTMLElement[], shiftKey: boo } const isSettingsPanelElement = (panel: HTMLElement, activeElement: Element | null): activeElement is Element => { - return activeElement instanceof Element && panel.contains(activeElement) + return activeElement instanceof Element + && (panel.contains(activeElement) || activeElement.closest('[data-settings-panel-portal="true"]') !== null) } const isSettingsFocusBoundary = (activeElement: Element, focusableElements: HTMLElement[], shiftKey: boolean): boolean => { diff --git a/src/constants/appStorage.test.ts b/src/constants/appStorage.test.ts new file mode 100644 index 00000000..596b8de5 --- /dev/null +++ b/src/constants/appStorage.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, copyLegacyAppStorageKeys, getAppStorageItem } from './appStorage' + +describe('appStorage legacy migration', () => { + let store: Record + + beforeEach(() => { + store = {} + vi.stubGlobal('localStorage', { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { store[key] = value }), + }) + }) + + it('copies legacy values to Tolaria keys without overwriting existing values', () => { + store[LEGACY_APP_STORAGE_KEYS.theme] = 'dark' + store[LEGACY_APP_STORAGE_KEYS.zoom] = '125' + store[APP_STORAGE_KEYS.zoom] = '100' + + copyLegacyAppStorageKeys() + + expect(store[APP_STORAGE_KEYS.theme]).toBe('dark') + expect(store[APP_STORAGE_KEYS.zoom]).toBe('100') + expect(store[APP_STORAGE_KEYS.legacyMigrationFlag]).toBe('1') + }) + + it('falls back to legacy values when the Tolaria key is absent', () => { + store[LEGACY_APP_STORAGE_KEYS.viewMode] = 'editor-list' + + expect(getAppStorageItem('viewMode')).toBe('editor-list') + }) + + it('returns safely when localStorage is restricted', () => { + vi.stubGlobal('localStorage', { + getItem: vi.fn(() => { throw new Error('SecurityError') }), + setItem: vi.fn(() => { throw new Error('SecurityError') }), + }) + + expect(() => copyLegacyAppStorageKeys()).not.toThrow() + expect(getAppStorageItem('theme')).toBeNull() + }) +}) diff --git a/src/extensions/frontmatterHighlight.ts b/src/extensions/frontmatterHighlight.ts index 2f697eac..e0263bae 100644 --- a/src/extensions/frontmatterHighlight.ts +++ b/src/extensions/frontmatterHighlight.ts @@ -19,14 +19,13 @@ function buildDecorations(view: EditorView): DecorationSet { const builder = new RangeSetBuilder() const doc = view.state.doc const fmEnd = findFrontmatterEnd(doc) + if (fmEnd === -1) return builder.finish() - for (let i = 1; i <= doc.lines; i++) { + for (let i = 1; i <= fmEnd; i++) { const line = doc.line(i) const text = line.text - if (i <= fmEnd) { - decorateFrontmatterLine(builder, line.from, text, i === 1 || i === fmEnd) - } + decorateFrontmatterLine(builder, line.from, text, i === 1 || i === fmEnd) } return builder.finish() @@ -64,7 +63,7 @@ export const frontmatterHighlightPlugin = ViewPlugin.fromClass( this.decorations = buildDecorations(view) } update(update: { docChanged: boolean; viewportChanged: boolean; view: EditorView }) { - if (update.docChanged || update.viewportChanged) { + if (update.docChanged) { this.decorations = buildDecorations(update.view) } } diff --git a/src/extensions/zoomCursorFix.test.ts b/src/extensions/zoomCursorFix.test.ts index 44bc3b4c..a76f7fa8 100644 --- a/src/extensions/zoomCursorFix.test.ts +++ b/src/extensions/zoomCursorFix.test.ts @@ -33,6 +33,12 @@ describe('getDocumentZoom', () => { spy.mockRestore() }) + it('normalizes a computed percentage zoom value', () => { + const spy = mockComputedZoom('125%') + expect(getDocumentZoom()).toBe(1.25) + spy.mockRestore() + }) + it('returns the zoom factor for sub-100% zoom', () => { const spy = mockComputedZoom('0.8') expect(getDocumentZoom()).toBe(0.8) diff --git a/src/extensions/zoomCursorFix.ts b/src/extensions/zoomCursorFix.ts index 4d467e8d..5d1d2078 100644 --- a/src/extensions/zoomCursorFix.ts +++ b/src/extensions/zoomCursorFix.ts @@ -1,26 +1,25 @@ import { EditorView, ViewPlugin } from '@codemirror/view' +function parseZoomValue(source: string | undefined): number | null { + const value = source?.trim() ?? '' + if (!value || value === 'normal') return null + + let parsed = parseFloat(value) + if (value.endsWith('%')) parsed /= 100 + return parsed > 0 && isFinite(parsed) ? parsed : null +} + /** * Read the current CSS zoom factor from document.documentElement. * Returns 1 when no zoom is applied or the value is unparseable. - * - * Checks getComputedStyle first (real browsers return the decimal value), - * then falls back to the inline style property (works in jsdom and test - * environments where getComputedStyle doesn't report zoom). */ export function getDocumentZoom(): number { - const computed = getComputedStyle(document.documentElement).zoom - if (computed && computed !== 'normal') { - const parsed = parseFloat(computed) - if (parsed > 0 && isFinite(parsed)) return parsed - } + const computedZoom = parseZoomValue(getComputedStyle(document.documentElement).zoom) + if (computedZoom !== null) return computedZoom const inline = document.documentElement.style.getPropertyValue('zoom') - if (inline && inline !== 'normal') { - let value = parseFloat(inline) - if (inline.endsWith('%')) value /= 100 - if (value > 0 && isFinite(value)) return value - } + const inlineZoom = parseZoomValue(inline) + if (inlineZoom !== null) return inlineZoom return 1 } @@ -69,6 +68,54 @@ function caretPosFromPoint( type Coords = { x: number; y: number } type PosAndSide = { pos: number; assoc: -1 | 1 } +type CoordsMethod = ( + this: EditorView, + coords: Coords, + precise?: boolean, +) => Result + +type EditorViewCoordsOverrides = { + posAtCoords?: CoordsMethod + posAndSideAtCoords?: CoordsMethod +} + +interface ZoomAwareCoordsCall { + self: EditorView + coords: Coords + precise: boolean | undefined + originalMethod: CoordsMethod + resultFromCaret: (pos: number) => Result +} + +function callZoomAwareCoords(call: ZoomAwareCoordsCall): Result { + const { self, coords, precise, originalMethod, resultFromCaret } = call + const zoom = getDocumentZoom() + if (zoom === 1) return originalMethod.call(self, coords, precise) + + const pos = caretPosFromPoint(self, coords.x, coords.y) + if (pos !== null) return resultFromCaret(pos) + + return originalMethod.call(self, adjustCoordsForZoom(coords, zoom), precise) +} + +function makeZoomCoordsOverride( + originalMethod: CoordsMethod, + resultFromCaret: (pos: number) => Result, +): CoordsMethod { + return function zoomAwareCoordsOverride( + this: EditorView, + coords: Coords, + precise?: boolean, + ): Result { + return callZoomAwareCoords({ + self: this, + coords, + precise, + originalMethod, + resultFromCaret, + }) + } +} /** * CodeMirror extension that fixes cursor positioning at non-100% CSS zoom. @@ -85,71 +132,24 @@ type PosAndSide = { pos: number; assoc: -1 | 1 } * factor if caretRangeFromPoint is unavailable or returns no result. */ export function zoomCursorFix() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - type AnyFn = (...args: any[]) => any - return ViewPlugin.define((view) => { - const origPosAtCoords: AnyFn = - Object.getPrototypeOf(view).posAtCoords - const origPosAndSideAtCoords: AnyFn = - Object.getPrototypeOf(view).posAndSideAtCoords - - function zoomPosAtCoords( - self: EditorView, - coords: Coords, - precise?: boolean, - ): number | null { - const zoom = getDocumentZoom() - if (zoom === 1) return origPosAtCoords.call(self, coords, precise) - - const pos = caretPosFromPoint(self, coords.x, coords.y) - if (pos !== null) return pos - - const adjusted = adjustCoordsForZoom(coords, zoom) - return origPosAtCoords.call(self, adjusted, precise) - } - - function zoomPosAndSideAtCoords( - self: EditorView, - coords: Coords, - precise?: boolean, - ): PosAndSide | null { - const zoom = getDocumentZoom() - if (zoom === 1) - return origPosAndSideAtCoords.call(self, coords, precise) - - const pos = caretPosFromPoint(self, coords.x, coords.y) - if (pos !== null) return { pos, assoc: 1 } - - const adjusted = adjustCoordsForZoom(coords, zoom) - return origPosAndSideAtCoords.call(self, adjusted, precise) - } + const prototype = Object.getPrototypeOf(view) as Required + const origPosAtCoords = prototype.posAtCoords + const origPosAndSideAtCoords = prototype.posAndSideAtCoords + const overrides = view as EditorViewCoordsOverrides // Override on the instance (shadows prototype methods) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(view as any).posAtCoords = function ( - this: EditorView, - coords: Coords, - precise?: boolean, - ) { - return zoomPosAtCoords(this, coords, precise) - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(view as any).posAndSideAtCoords = function ( - this: EditorView, - coords: Coords, - precise?: boolean, - ) { - return zoomPosAndSideAtCoords(this, coords, precise) - } + overrides.posAtCoords = makeZoomCoordsOverride(origPosAtCoords, (pos) => pos) + overrides.posAndSideAtCoords = makeZoomCoordsOverride( + origPosAndSideAtCoords, + (pos) => ({ pos, assoc: 1 }), + ) return { destroy() { // Remove instance overrides, restoring prototype methods - // eslint-disable-next-line @typescript-eslint/no-explicit-any - delete (view as any).posAtCoords - // eslint-disable-next-line @typescript-eslint/no-explicit-any - delete (view as any).posAndSideAtCoords + delete overrides.posAtCoords + delete overrides.posAndSideAtCoords }, } }) diff --git a/src/test/setup.test.ts b/src/test/setup.test.ts new file mode 100644 index 00000000..2980ca26 --- /dev/null +++ b/src/test/setup.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it, vi } from 'vitest' + +describe('test setup fetch mock isolation', () => { + it('allows a test to override the shared fetch mock', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 200 })) + + await expect(fetch('/override').then((response) => response.status)).resolves.toBe(200) + }) + + it('restores the default fetch mock after each test', async () => { + await expect(fetch('/default').then((response) => response.status)).resolves.toBe(418) + }) +}) diff --git a/src/test/setup.ts b/src/test/setup.ts index 5b21ceea..d98ddc02 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -7,9 +7,8 @@ import { createElement, type ReactNode, type ComponentType } from 'react' // undici rejects with InvalidArgumentError (UND_ERR_INVALID_ARG). // Tests should never make real network requests — individual tests can // override this stub via vi.mocked(fetch).mockImplementation(...). -globalThis.fetch = vi.fn(() => - Promise.resolve(new Response(null, { status: 418 })), -) as typeof globalThis.fetch +const defaultFetchMock = () => Promise.resolve(new Response(null, { status: 418 })) +globalThis.fetch = vi.fn(defaultFetchMock) as typeof globalThis.fetch // Stub WebSocket to prevent Node 22 + undici WebSocket incompatibility. // undici's WebSocket dispatchEvent crashes with "The event argument must be @@ -62,6 +61,11 @@ vi.mock('@tauri-apps/plugin-opener', () => ({ afterEach(() => { vi.clearAllTimers() vi.useRealTimers() + if (vi.isMockFunction(globalThis.fetch)) { + vi.mocked(globalThis.fetch).mockReset().mockImplementation(defaultFetchMock) + } else { + globalThis.fetch = vi.fn(defaultFetchMock) as typeof globalThis.fetch + } }) // Mock react-day-picker: Calendar component uses DayPicker which needs real DOM APIs not available in jsdom diff --git a/src/utils/ai-agent.test.ts b/src/utils/ai-agent.test.ts index e2f76609..038284b3 100644 --- a/src/utils/ai-agent.test.ts +++ b/src/utils/ai-agent.test.ts @@ -23,14 +23,25 @@ describe('buildAgentSystemPrompt', () => { expect(prompt).toContain('Recent notes: foo, bar') }) - it('points agents to bundled Tolaria docs when available', () => { + it('points safe-mode agents to bundled Tolaria docs without shell commands', () => { const prompt = buildAgentSystemPrompt({ agentDocsPath: '/app/agent-docs' }) expect(prompt).toContain('/app/agent-docs/index.md') - expect(prompt).toContain('ripgrep') + expect(prompt).not.toContain('ripgrep') expect(prompt).toContain('Prefer bundled docs over guesses') }) + it('keeps ripgrep guidance for bundled docs in shell-capable power user mode', () => { + const prompt = buildAgentSystemPrompt({ + agent: 'codex', + agentDocsPath: '/app/agent-docs', + permissionMode: 'power_user', + }) + + expect(prompt).toContain('ripgrep') + expect(prompt).toContain('Power User mode is active') + }) + it('allows shell commands in power user mode where supported', () => { const prompt = buildAgentSystemPrompt({ agent: 'codex', permissionMode: 'power_user' }) expect(prompt).toContain('Power User mode is active') diff --git a/src/utils/ai-agent.ts b/src/utils/ai-agent.ts index 501e5f78..3a10ef4b 100644 --- a/src/utils/ai-agent.ts +++ b/src/utils/ai-agent.ts @@ -40,16 +40,23 @@ function permissionModeInstructions( return `Vault Safe mode is active. Do not use shell, terminal, Bash, Python/Node script execution, git, or command-line tools. If the user asks whether shell commands are available, say they are not available in Vault Safe. Use file/search/edit tools and Tolaria MCP tools instead.` } -function agentDocsInstructions(agentDocsPath?: string): string { +function agentDocsInstructions( + agentDocsPath: string | undefined, + canUseShell: boolean, +): string { if (!agentDocsPath) { return `Read the vault's AGENTS.md when one exists before making vault-specific assumptions.` } + const searchInstruction = canUseShell + ? `Start with ${agentDocsPath}/index.md, then use ripgrep over that folder for specific concepts.` + : `Start with ${agentDocsPath}/index.md, then use the available file and search tools for specific concepts.` + return `Read the vault's AGENTS.md when one exists before making vault-specific assumptions. For Tolaria product behavior, workflows, and user questions about how Tolaria works, search the bundled local docs at: ${agentDocsPath} -Start with ${agentDocsPath}/index.md, then use ripgrep over that folder for specific concepts. Prefer bundled docs over guesses for Tolaria behavior.` +${searchInstruction} Prefer bundled docs over guesses for Tolaria behavior.` } function vaultScopeInstructions(vaultPaths?: string[]): string { @@ -77,10 +84,11 @@ Be concise and helpful. When you've completed a task, briefly summarize what you export function buildAgentSystemPrompt(options?: string | AgentSystemPromptOptions): string { const { vaultContext, agentDocsPath, permissionMode, agent, vaultPaths } = normalizePromptOptions(options) + const canUseShell = permissionMode === 'power_user' && agent !== 'pi' const prompt = [ AGENT_SYSTEM_PREAMBLE, vaultScopeInstructions(vaultPaths), - agentDocsInstructions(agentDocsPath), + agentDocsInstructions(agentDocsPath, canUseShell), permissionModeInstructions(permissionMode, agent), ].join('\n\n') diff --git a/src/utils/calendarVersion.test.ts b/src/utils/calendarVersion.test.ts new file mode 100644 index 00000000..171f36cf --- /dev/null +++ b/src/utils/calendarVersion.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { formatCalendarVersionForDisplay } from './calendarVersion' + +describe('formatCalendarVersionForDisplay', () => { + it('formats valid calendar and alpha versions', () => { + expect(formatCalendarVersionForDisplay('2026.5.17')).toBe('2026.5.17') + expect(formatCalendarVersionForDisplay('2026.5.17-alpha.1')).toBe('Alpha 2026.5.17.1') + }) + + it('rejects malformed prerelease suffixes', () => { + expect(formatCalendarVersionForDisplay('2026.5.17-alpha.1-extra')).toBeNull() + expect(formatCalendarVersionForDisplay('2026.5.17-stable.1-extra')).toBeNull() + }) +}) diff --git a/src/utils/calendarVersion.ts b/src/utils/calendarVersion.ts index c9462271..ddaf2837 100644 --- a/src/utils/calendarVersion.ts +++ b/src/utils/calendarVersion.ts @@ -24,7 +24,10 @@ function isVersionNumberPart(value: string, expectedLength?: number): boolean { } function parseCalendarVersion(version: string): CalendarVersionParts | null { - const [calendar, prerelease] = version.split('-', 2) + const versionParts = version.split('-') + if (versionParts.length > 2) return null + + const [calendar, prerelease] = versionParts const calendarVersion = parseCalendarVersionParts(calendar) if (!calendarVersion) return null if (prerelease === undefined) return calendarVersion diff --git a/src/utils/wikilinks.test.ts b/src/utils/wikilinks.test.ts index a08fba73..b26bcad6 100644 --- a/src/utils/wikilinks.test.ts +++ b/src/utils/wikilinks.test.ts @@ -54,6 +54,21 @@ describe('preProcessWikilinks', () => { expect(result).not.toContain('[[project/beta|Project Beta]]') }) + it('leaves wikilinks inside fenced code unchanged', () => { + const input = [ + 'Before [[Real Note]]', + '', + '```ts', + "const sample = '[[Not a note]]'", + '```', + ].join('\n') + + const result = preProcessWikilinks(input) + + expect(result).toContain('WIKILINK:Real Note') + expect(result).toContain('[[Not a note]]') + }) + it('handles empty string', () => { expect(preProcessWikilinks('')).toBe('') }) @@ -491,6 +506,18 @@ describe('extractOutgoingLinks', () => { expect(extractOutgoingLinks(content)).toEqual(['First', 'Second', 'Third']) }) + it('ignores wikilinks inside fenced code blocks', () => { + const content = [ + 'See [[Real Note]].', + '', + '```ts', + "const sample = '[[Not a note]]'", + '```', + ].join('\n') + + expect(extractOutgoingLinks(content)).toEqual(['Real Note']) + }) + it('ignores empty wikilinks', () => { const content = 'Text [[]] and [[Valid]]' expect(extractOutgoingLinks(content)).toEqual(['Valid']) @@ -554,6 +581,20 @@ describe('extractBacklinkContext', () => { expect(result).toBe('First [[My Note]] mention.') }) + it('ignores backlink matches inside fenced code blocks', () => { + const content = [ + '# Test', + '', + '```ts', + "const sample = '[[My Note]]'", + '```', + '', + 'No real backlink here.', + ].join('\n') + + expect(extractBacklinkContext(content, targets)).toBeNull() + }) + it('does not return paragraph when maxLength is respected', () => { const content = '---\ntitle: X\n---\n\n# X\n\nShort [[My Note]].' const result = extractBacklinkContext(content, targets, 200) diff --git a/src/utils/wikilinks.ts b/src/utils/wikilinks.ts index cf4d3efc..2c97c4b8 100644 --- a/src/utils/wikilinks.ts +++ b/src/utils/wikilinks.ts @@ -20,14 +20,23 @@ type TokenSequence = string type ParsedTextRange = { text: MarkdownSource, nextIndex: TextOffset } type MatchTargets = Set type WordCount = number +type FenceMarker = string | null /** Pre-process markdown: replace [[target]] with placeholder tokens */ export function preProcessWikilinks(md: MarkdownSource): MarkdownSource { const lines = md.split('\n') const tableLines = findMarkdownTableLines(lines) - return lines.map((line, index) => ( - replaceWikilinksWithPlaceholders(line, { encodePayload: tableLines.at(index) ?? false }) - )).join('\n') + let fenceMarker: FenceMarker = null + + return lines.map((line, index) => { + const nextFenceMarker = nextMarkdownFenceMarker(line, fenceMarker) + if (nextFenceMarker !== fenceMarker || fenceMarker !== null) { + fenceMarker = nextFenceMarker + return line + } + + return replaceWikilinksWithPlaceholders(line, { encodePayload: tableLines.at(index) ?? false }) + }).join('\n') } // Minimal shape of a BlockNote block for wikilink processing @@ -100,12 +109,41 @@ function decodePlaceholderPayload(payload: PlaceholderPayload): WikilinkTarget { } } +function lineFenceMarker(line: MarkdownLine): FenceMarker { + return line.trimStart().match(/^(`{3,}|~{3,})/)?.[1] ?? null +} + +function nextMarkdownFenceMarker(line: MarkdownLine, currentMarker: FenceMarker): FenceMarker { + const marker = lineFenceMarker(line) + if (!marker) return currentMarker + if (!currentMarker) return marker + return marker[0] === currentMarker[0] && marker.length >= currentMarker.length ? null : currentMarker +} + +function blankFencedCodeLines(content: MarkdownSource): MarkdownSource { + let fenceMarker: FenceMarker = null + return content.split('\n').map((line) => { + const nextFenceMarker = nextMarkdownFenceMarker(line, fenceMarker) + const shouldBlank = fenceMarker !== null || nextFenceMarker !== fenceMarker + fenceMarker = nextFenceMarker + return shouldBlank ? '' : line + }).join('\n') +} + function findMarkdownTableLines(lines: MarkdownLines): TableLineMap { const tableLines = lines.map(() => false) + let fenceMarker: FenceMarker = null + for (let index = 0; index < lines.length - 1; index++) { const line = lines.at(index) const nextLine = lines.at(index + 1) if (line === undefined || nextLine === undefined) continue + const nextFenceMarker = nextMarkdownFenceMarker(line, fenceMarker) + if (fenceMarker !== null || nextFenceMarker !== fenceMarker) { + fenceMarker = nextFenceMarker + continue + } + if (!isPotentialTableRow(line) || !isMarkdownTableSeparator(nextLine)) { continue } @@ -298,11 +336,15 @@ export function extractOutgoingLinks(content: MarkdownSource): WikilinkTarget[] const links: WikilinkTarget[] = [] const re = /\[\[([^\]]+)\]\]/g let match - while ((match = re.exec(content)) !== null) { - const inner = match[1] - const pipeIdx = inner.indexOf('|') - const target = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner - if (target) links.push(target) + const searchableContent = blankFencedCodeLines(content) + for (const line of searchableContent.split('\n')) { + re.lastIndex = 0 + while ((match = re.exec(line)) !== null) { + const inner = match[1] + const pipeIdx = inner.indexOf('|') + const target = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner + if (target) links.push(target) + } } return [...new Set(links)].sort() } @@ -316,8 +358,9 @@ export function extractBacklinkContext( maxLength: CharacterCount = 120, ): MarkdownSource | null { const [, body] = splitFrontmatter(content) + const searchableBody = blankFencedCodeLines(body) // Remove the H1 title line - const withoutTitle = body.replace(/^\s*# [^\n]+\n?/, '') + const withoutTitle = searchableBody.replace(/^\s*# [^\n]+\n?/, '') const paragraphs = withoutTitle.split(/\n{2,}/) for (const para of paragraphs) { diff --git a/src/utils/windowMode.test.ts b/src/utils/windowMode.test.ts index 0d1e16b0..a548e1e4 100644 --- a/src/utils/windowMode.test.ts +++ b/src/utils/windowMode.test.ts @@ -190,6 +190,7 @@ describe('windowMode', () => { })).toEqual([ 'demo-vault-v2/untitled-note-29.md', '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2/untitled-note-29.md', + 'untitled-note-29.md', ]) }) @@ -219,5 +220,15 @@ describe('windowMode', () => { vaultPath: '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2', })).toBeUndefined() }) + + it('prefers exact relative-path matches over same-suffix entries', () => { + const archivedEntry = makeEntry('archive/folder/note.md', 'Archived Note') + const liveEntry = makeEntry('folder/note.md', 'Live Note') + + expect(findNoteWindowEntry([archivedEntry, liveEntry], { + notePath: 'folder/note.md', + vaultPath: '/vault', + })).toBe(liveEntry) + }) }) }) diff --git a/src/utils/windowMode.ts b/src/utils/windowMode.ts index bd298725..4736b7cb 100644 --- a/src/utils/windowMode.ts +++ b/src/utils/windowMode.ts @@ -109,10 +109,24 @@ function stripKnownVaultPrefix({ notePath, vaultPath }: NoteWindowPathContext): if (vaultName && normalizedPath.startsWith(`${vaultName}/`)) { return normalizedPath.slice(vaultName.length + 1) } + if (vaultName) { + const embeddedVaultPrefix = `/${vaultName}/` + const embeddedVaultIndex = normalizedPath.indexOf(embeddedVaultPrefix) + if (embeddedVaultIndex !== -1) { + return normalizedPath.slice(embeddedVaultIndex + embeddedVaultPrefix.length) + } + } return normalizedPath.replace(/^\/+/, '') } +function getVaultRelativeCandidate(vaultName: string | undefined, relativePath: string): string | null { + if (!vaultName) return null + if (!relativePath) return null + if (relativePath.startsWith(`${vaultName}/`)) return null + return `${vaultName}/${relativePath}` +} + export function getNoteWindowPathCandidates({ notePath, vaultPath }: NoteWindowPathContext): string[] { const normalizedPath = trimTrailingSlash(notePath) const normalizedVaultPath = trimTrailingSlash(vaultPath) @@ -122,13 +136,22 @@ export function getNoteWindowPathCandidates({ notePath, vaultPath }: NoteWindowP if (normalizedVaultPath) { candidates.add(`${normalizedVaultPath}/${relativePath}`) } + if (relativePath !== normalizedPath) { + candidates.add(relativePath) + } + const vaultName = normalizedVaultPath.split('/').pop() + const vaultRelativeCandidate = getVaultRelativeCandidate(vaultName, relativePath) + if (vaultRelativeCandidate) candidates.add(vaultRelativeCandidate) + const withoutLeadingSlash = normalizedPath.replace(/^\/+/, '') + if (withoutLeadingSlash !== normalizedPath) { + candidates.add(withoutLeadingSlash) + } return [...candidates] } function pathsMatch(leftPath: string, rightPath: string): boolean { - if (leftPath === rightPath) return true - return leftPath.endsWith(`/${rightPath}`) || rightPath.endsWith(`/${leftPath}`) + return leftPath === rightPath } function variantsOverlap(left: Set, right: Set): boolean {