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 {