diff --git a/frontend/src/components/settings/SkillsEditor.test.tsx b/frontend/src/components/settings/SkillsEditor.test.tsx
index 6eb47552..79d5b07c 100644
--- a/frontend/src/components/settings/SkillsEditor.test.tsx
+++ b/frontend/src/components/settings/SkillsEditor.test.tsx
@@ -82,7 +82,8 @@ describe('SkillsEditor', () => {
await user.click(screen.getByText('Install Skill'))
const urlInput = screen.getByPlaceholderText('Paste a GitHub skill URL')
- await user.type(urlInput, 'https://github.com/mattpocock/skills/tree/main/skills/productivity/teach')
+ await user.click(urlInput)
+ await user.paste('https://github.com/mattpocock/skills/tree/main/skills/productivity/teach')
await user.click(screen.getByText('Install'))
@@ -143,7 +144,8 @@ describe('SkillsEditor', () => {
await user.click(screen.getByText('Install Skill'))
const urlInput = screen.getByPlaceholderText('Paste a GitHub skill URL')
- await user.type(urlInput, 'https://github.com/mattpocock/skills/tree/main/skills/productivity/teach')
+ await user.click(urlInput)
+ await user.paste('https://github.com/mattpocock/skills/tree/main/skills/productivity/teach')
await user.click(screen.getByText('Install'))
diff --git a/frontend/src/components/ui/code-editor.test.tsx b/frontend/src/components/ui/code-editor.test.tsx
new file mode 100644
index 00000000..b3411ffa
--- /dev/null
+++ b/frontend/src/components/ui/code-editor.test.tsx
@@ -0,0 +1,291 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { CodeEditor } from './code-editor'
+
+describe('CodeEditor', () => {
+ it('renders a line number for every logical line', () => {
+ render(
)
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.getByText('2')).toBeInTheDocument()
+ expect(screen.getByText('3')).toBeInTheDocument()
+ })
+
+ it('renders a trailing empty line number when the value ends in a newline', () => {
+ render(
)
+ expect(screen.getByText('2')).toBeInTheDocument()
+ })
+
+ it('reports edits through onChange', async () => {
+ const onChange = vi.fn()
+ const user = userEvent.setup()
+ render(
)
+ await user.type(screen.getByLabelText('config'), 'x')
+ expect(onChange).toHaveBeenCalledWith('x')
+ })
+
+ it('applies identical font, wrap and tab metrics to the textarea and the mirror', () => {
+ const { container } = render(
)
+ const textarea = screen.getByLabelText('config')
+ const mirror = container.querySelector('[data-editor-mirror]') as HTMLElement
+ for (const token of ['font-mono', 'text-[16px]', 'min-[769px]:text-sm', 'leading-6', '[tab-size:2]', 'whitespace-pre-wrap', '[overflow-wrap:anywhere]', 'py-2', 'pr-3', 'pl-10', '[scrollbar-gutter:stable]']) {
+ expect(textarea.className).toContain(token)
+ expect(mirror.className).toContain(token)
+ }
+ })
+
+ it('keeps the mirror hidden from assistive tech', () => {
+ const { container } = render(
)
+ expect(container.querySelector('[data-editor-mirror]')).toHaveAttribute('aria-hidden', 'true')
+ })
+
+ it('mirrors textarea scrolling', () => {
+ const { container } = render(
)
+ const textarea = screen.getByLabelText('config') as HTMLTextAreaElement
+ const mirror = container.querySelector('[data-editor-mirror]') as HTMLElement
+ textarea.scrollTop = 120
+ textarea.dispatchEvent(new Event('scroll', { bubbles: true }))
+ expect(mirror.scrollTop).toBe(120)
+ })
+
+ const HIGHLIGHT_VALUE = '{\n "model": "sonnet",\n "theme": "sonnet"\n}'
+
+ it('renders a mark for each highlight', () => {
+ const { container } = render(
+
,
+ )
+ const marks = container.querySelectorAll('mark')
+ expect(marks).toHaveLength(2)
+ expect(marks[0]).toHaveTextContent('sonnet')
+ })
+
+ it('flags only the active match', () => {
+ const { container } = render(
+
,
+ )
+ const active = container.querySelectorAll('mark[data-active-match="true"]')
+ expect(active).toHaveLength(1)
+ expect(active[0]).toBe(container.querySelectorAll('mark')[1])
+ })
+
+ it('keeps highlights rendered while the textarea is focused', async () => {
+ const user = userEvent.setup()
+ const { container } = render(
+
,
+ )
+ await user.click(screen.getByLabelText('config'))
+ expect(container.querySelectorAll('mark')).toHaveLength(1)
+ })
+
+ it('hides the textarea glyphs only while highlights are active', () => {
+ const { container, rerender } = render(
)
+ expect(screen.getByLabelText('config').className).not.toContain('text-transparent')
+ expect((container.querySelector('[data-editor-mirror]') as HTMLElement).className).toContain('text-transparent')
+
+ rerender(
)
+ expect(screen.getByLabelText('config').className).toContain('text-transparent')
+ expect((container.querySelector('[data-editor-mirror]') as HTMLElement).className).not.toContain('text-transparent')
+ })
+
+ it('splits a highlight that spans a newline across both rows', () => {
+ const { container } = render(
+
,
+ )
+ const rows = container.querySelectorAll('[data-line]')
+ expect(rows[0].querySelector('mark')).toHaveTextContent('c')
+ expect(rows[1].querySelector('mark')).toHaveTextContent('d')
+ const active = container.querySelectorAll('mark[data-active-match="true"]')
+ expect(active).toHaveLength(1)
+ expect(active[0]).toBe(rows[0].querySelector('mark'))
+ })
+
+ it('buckets out-of-order highlights to the correct rows without re-scanning', () => {
+ const { container } = render(
+
,
+ )
+ const rows = container.querySelectorAll('[data-line]')
+ expect(rows[0].querySelector('mark')).toHaveTextContent('aa')
+ expect(rows[2].querySelector('mark')).toHaveTextContent('cccc')
+ expect(container.querySelectorAll('mark')).toHaveLength(2)
+ })
+
+ it('marks the active line row', () => {
+ const { container } = render(
)
+ const banded = container.querySelectorAll('[data-active-line]')
+ expect(banded).toHaveLength(1)
+ expect(banded[0].closest('[data-line]')).toHaveAttribute('data-line', '2')
+ })
+
+ it('flags the active line number', () => {
+ const { container } = render(
)
+ const number = container.querySelector('[data-line="2"] [data-line-number]') as HTMLElement
+ expect(number.className).toContain('text-destructive')
+ })
+
+ it('scrolls the active line into view', () => {
+ const longValue = Array.from({ length: 40 }, (_, i) => `line ${i}`).join('\n')
+ const { container, rerender } = render(
)
+ const textarea = screen.getByLabelText('config') as HTMLTextAreaElement
+ Object.defineProperty(textarea, 'clientHeight', { configurable: true, value: 200 })
+ Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 960 })
+ const row = container.querySelector('[data-line="30"]') as HTMLElement
+ Object.defineProperty(row, 'offsetTop', { configurable: true, value: 700 })
+ Object.defineProperty(row, 'offsetHeight', { configurable: true, value: 24 })
+
+ rerender(
)
+
+ const mirror = container.querySelector('[data-editor-mirror]') as HTMLElement
+ expect(textarea.scrollTop).toBe(612)
+ expect(mirror.scrollTop).toBe(612)
+ })
+
+ function rect(top: number, height: number): DOMRect {
+ return {
+ top,
+ bottom: top + height,
+ left: 0,
+ right: 0,
+ width: 0,
+ height,
+ x: 0,
+ y: top,
+ toJSON: () => ({}),
+ } as DOMRect
+ }
+
+ function patchRect(target: Element, value: DOMRect) {
+ Object.defineProperty(target, 'getBoundingClientRect', {
+ configurable: true,
+ value: () => value,
+ })
+ }
+
+ it('reveals the active match at the bottom of a tall wrapped line', () => {
+ const startMatch = 'needleStart'
+ const endMatch = 'needleEnd'
+ const longValue = startMatch + 'x'.repeat(2000) + endMatch
+ const startIdx = 0
+ const startEnd = startMatch.length
+ const endIdx = longValue.indexOf(endMatch)
+ const endEnd = endIdx + endMatch.length
+
+ const { container, rerender } = render(
+
,
+ )
+ const textarea = screen.getByLabelText('config') as HTMLTextAreaElement
+ Object.defineProperty(textarea, 'clientHeight', { configurable: true, value: 200 })
+ Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 1600 })
+ const mirror = container.querySelector('[data-editor-mirror]') as HTMLElement
+ patchRect(mirror, rect(0, 1600))
+ const marks = container.querySelectorAll('mark')
+ patchRect(marks[1], rect(1500, 24))
+
+ rerender(
+
,
+ )
+ expect(textarea.scrollTop).toBe(1400)
+ expect(mirror.scrollTop).toBe(1400)
+ })
+
+ it('reveals the active match at the top of a tall wrapped line', () => {
+ const startMatch = 'needleStart'
+ const endMatch = 'needleEnd'
+ const longValue = startMatch + 'x'.repeat(2000) + endMatch
+ const startIdx = 0
+ const startEnd = startMatch.length
+ const endIdx = longValue.indexOf(endMatch)
+ const endEnd = endIdx + endMatch.length
+
+ const { container, rerender } = render(
+
,
+ )
+ const textarea = screen.getByLabelText('config') as HTMLTextAreaElement
+ Object.defineProperty(textarea, 'clientHeight', { configurable: true, value: 200 })
+ Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 1600 })
+ const mirror = container.querySelector('[data-editor-mirror]') as HTMLElement
+ patchRect(mirror, rect(0, 1600))
+ const marks = container.querySelectorAll('mark')
+ patchRect(marks[0], rect(0, 24))
+
+ rerender(
+
,
+ )
+ expect(textarea.scrollTop).toBe(0)
+ expect(mirror.scrollTop).toBe(0)
+ })
+
+ it('falls back to line reveal when the active mark is not present', () => {
+ const longValue = Array.from({ length: 40 }, (_, i) => `line ${i}`).join('\n')
+ const startIdx = 0
+ const startEnd = 4
+
+ const { container, rerender } = render(
+
,
+ )
+ const textarea = screen.getByLabelText('config') as HTMLTextAreaElement
+ Object.defineProperty(textarea, 'clientHeight', { configurable: true, value: 200 })
+ Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 960 })
+ const mirror = container.querySelector('[data-editor-mirror]') as HTMLElement
+ patchRect(mirror, rect(0, 0))
+ const marks = container.querySelectorAll('mark')
+ patchRect(marks[0], rect(0, 0))
+
+ rerender(
+
,
+ )
+ expect(textarea.scrollTop).toBe(0)
+ })
+
+ it('leaves the scroll position alone while typing away from the active match', () => {
+ const value = 'needle\n' + Array.from({ length: 40 }, (_, i) => `line ${i}`).join('\n')
+
+ const { container, rerender } = render(
+
,
+ )
+ const textarea = screen.getByLabelText('config') as HTMLTextAreaElement
+ Object.defineProperty(textarea, 'clientHeight', { configurable: true, value: 200 })
+ Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 960 })
+ const mirror = container.querySelector('[data-editor-mirror]') as HTMLElement
+ patchRect(mirror, rect(0, 960))
+ patchRect(container.querySelectorAll('mark')[0], rect(0, 24))
+
+ textarea.scrollTop = 500
+ mirror.scrollTop = 500
+
+ rerender(
+
,
+ )
+
+ expect(textarea.scrollTop).toBe(500)
+ expect(mirror.scrollTop).toBe(500)
+ })
+})
diff --git a/frontend/src/components/ui/code-editor.tsx b/frontend/src/components/ui/code-editor.tsx
new file mode 100644
index 00000000..ada741f1
--- /dev/null
+++ b/frontend/src/components/ui/code-editor.tsx
@@ -0,0 +1,292 @@
+import { useMemo, useRef, useEffect, forwardRef, useCallback, memo, type ReactNode } from 'react'
+import { cn } from '@/lib/utils'
+import { computeScrollTopForRow } from '@/lib/editorScroll'
+
+interface CodeEditorHighlight {
+ startIndex: number
+ endIndex: number
+}
+
+interface CodeEditorProps {
+ value: string
+ onChange: (value: string) => void
+ highlights?: CodeEditorHighlight[]
+ activeHighlightIndex?: number
+ activeLine?: number | null
+ revealNonce?: number
+ autoFocus?: boolean
+ disabled?: boolean
+ placeholder?: string
+ id?: string
+ ariaLabel: string
+ className?: string
+}
+
+const SURFACE_CLASS =
+ 'font-mono text-[16px] min-[769px]:text-sm leading-6 [tab-size:2] whitespace-pre-wrap [overflow-wrap:anywhere] py-2 pr-3 pl-10 [scrollbar-gutter:stable]'
+
+interface RowHighlight {
+ start: number
+ end: number
+ index: number
+ first: boolean
+}
+
+const EMPTY_ROW_HIGHLIGHTS: RowHighlight[] = []
+
+function lineNumberForOffset(lineStarts: number[], offset: number): number {
+ let lo = 0
+ let hi = lineStarts.length - 1
+ while (lo < hi) {
+ const mid = (lo + hi + 1) >> 1
+ if (lineStarts[mid] <= offset) lo = mid
+ else hi = mid - 1
+ }
+ return lo + 1
+}
+
+interface EditorRowProps {
+ line: string
+ lineNumber: number
+ isActiveLine: boolean
+ highlights: RowHighlight[]
+ activeHighlightIndex?: number
+}
+
+const EditorRow = memo(function EditorRow({
+ line,
+ lineNumber,
+ isActiveLine,
+ highlights,
+ activeHighlightIndex,
+}: EditorRowProps) {
+ return (
+
+
+ {lineNumber}
+
+ {isActiveLine && (
+
+ )}
+ {highlights.length === 0
+ ? line.length > 0
+ ? line
+ : '\u200b'
+ : highlights.map((h, i) => {
+ const previous = i === 0 ? 0 : highlights[i - 1].end
+ const nodes: ReactNode[] = []
+ if (h.start > previous) {
+ nodes.push(line.substring(previous, h.start))
+ }
+ nodes.push(
+
+ {line.substring(h.start, h.end)}
+ ,
+ )
+ if (i === highlights.length - 1 && h.end < line.length) {
+ nodes.push(line.substring(h.end))
+ }
+ return {nodes}
+ })}
+
+ )
+})
+
+export const CodeEditor = forwardRef
(function CodeEditor(
+ {
+ value,
+ onChange,
+ highlights,
+ activeHighlightIndex,
+ activeLine,
+ revealNonce = 0,
+ autoFocus,
+ disabled,
+ placeholder,
+ id,
+ ariaLabel,
+ className,
+ },
+ ref,
+) {
+ const lines = useMemo(() => value.split('\n'), [value])
+
+ const lineStarts = useMemo(() => {
+ const starts: number[] = []
+ let acc = 0
+ for (const line of lines) {
+ starts.push(acc)
+ acc += line.length + 1
+ }
+ return starts
+ }, [lines])
+
+ const rowHighlights = useMemo(() => {
+ if (!highlights?.length) return null
+ const buckets: RowHighlight[][] = lines.map(() => [])
+ const ordered = highlights
+ .map((h, index) => ({ startIndex: h.startIndex, endIndex: h.endIndex, index }))
+ .sort((a, b) => a.startIndex - b.startIndex)
+ let cursor = 0
+ for (const highlight of ordered) {
+ while (cursor < lines.length && lineStarts[cursor] + lines[cursor].length < highlight.startIndex) {
+ cursor += 1
+ }
+ let line = cursor
+ let isFirst = true
+ while (line < lines.length) {
+ const lineStart = lineStarts[line]
+ if (lineStart > highlight.endIndex) break
+ const lineEnd = lineStart + lines[line].length
+ if (highlight.endIndex > lineStart && highlight.startIndex < lineEnd) {
+ buckets[line].push({
+ start: Math.max(highlight.startIndex, lineStart) - lineStart,
+ end: Math.min(highlight.endIndex, lineEnd) - lineStart,
+ index: highlight.index,
+ first: isFirst,
+ })
+ isFirst = false
+ }
+ line += 1
+ }
+ }
+ return buckets
+ }, [highlights, lines, lineStarts])
+
+ const hasHighlights = (highlights?.length ?? 0) > 0
+
+ const textareaRef = useRef(null)
+ const mirrorRef = useRef(null)
+
+ const syncMirrorScroll = useCallback(() => {
+ const textarea = textareaRef.current
+ const mirror = mirrorRef.current
+ if (!textarea || !mirror) return
+ mirror.scrollTop = textarea.scrollTop
+ mirror.scrollLeft = textarea.scrollLeft
+ }, [])
+
+ const combinedRef = useCallback(
+ (node: HTMLTextAreaElement | null) => {
+ textareaRef.current = node
+ if (typeof ref === 'function') {
+ ref(node)
+ } else if (ref) {
+ ref.current = node
+ }
+ },
+ [ref],
+ )
+
+ const revealRange = useCallback((top: number, height: number) => {
+ const textarea = textareaRef.current
+ const mirror = mirrorRef.current
+ if (!textarea || !mirror) return
+ const next = computeScrollTopForRow({
+ rowTop: top,
+ rowHeight: height,
+ viewportHeight: textarea.clientHeight,
+ maxScrollTop: Math.max(0, textarea.scrollHeight - textarea.clientHeight),
+ })
+ textarea.scrollTop = next
+ mirror.scrollTop = next
+ }, [])
+
+ const revealLine = useCallback((lineNumber: number) => {
+ const mirror = mirrorRef.current
+ const row = mirror?.querySelector(`[data-line="${lineNumber}"]`)
+ if (!row) return
+ revealRange(row.offsetTop, row.offsetHeight)
+ }, [revealRange])
+
+ const revealActiveMark = useCallback(() => {
+ const mirror = mirrorRef.current
+ const mark = mirror?.querySelector('mark[data-active-match="true"]')
+ if (!mirror || !mark) return false
+ const mirrorRect = mirror.getBoundingClientRect()
+ const markRect = mark.getBoundingClientRect()
+ revealRange(markRect.top - mirrorRect.top + mirror.scrollTop, markRect.height)
+ return true
+ }, [revealRange])
+
+ useEffect(() => {
+ if (activeLine == null) return
+ revealLine(activeLine)
+ }, [activeLine, revealNonce, revealLine])
+
+ const activeHighlightStart =
+ activeHighlightIndex == null ? null : highlights?.[activeHighlightIndex]?.startIndex ?? null
+
+ const lineStartsRef = useRef(lineStarts)
+ lineStartsRef.current = lineStarts
+
+ useEffect(() => {
+ if (activeHighlightStart == null) return
+ if (revealActiveMark()) return
+ revealLine(lineNumberForOffset(lineStartsRef.current, activeHighlightStart))
+ }, [activeHighlightStart, revealNonce, revealLine, revealActiveMark])
+
+ return (
+
+
+ {lines.map((line, index) => (
+
+ ))}
+
+
+ )
+})
diff --git a/frontend/src/components/ui/confirm-destructive-dialog.tsx b/frontend/src/components/ui/confirm-destructive-dialog.tsx
new file mode 100644
index 00000000..3ba98499
--- /dev/null
+++ b/frontend/src/components/ui/confirm-destructive-dialog.tsx
@@ -0,0 +1,70 @@
+import type { ReactNode } from 'react'
+import { AlertTriangle } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+
+interface ConfirmDestructiveDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onConfirm: () => void
+ onCancel: () => void
+ title: string
+ description: ReactNode
+ warning?: ReactNode
+ confirmLabel: string
+ pendingLabel?: string
+ cancelLabel?: string
+ isPending?: boolean
+}
+
+export function ConfirmDestructiveDialog({
+ open,
+ onOpenChange,
+ onConfirm,
+ onCancel,
+ title,
+ description,
+ warning,
+ confirmLabel,
+ pendingLabel,
+ cancelLabel = 'Cancel',
+ isPending = false,
+}: ConfirmDestructiveDialogProps) {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ui/delete-dialog.tsx b/frontend/src/components/ui/delete-dialog.tsx
index b2c05db2..0550253f 100644
--- a/frontend/src/components/ui/delete-dialog.tsx
+++ b/frontend/src/components/ui/delete-dialog.tsx
@@ -1,7 +1,5 @@
-import { Button } from '@/components/ui/button'
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Alert, AlertDescription } from '@/components/ui/alert'
-import { AlertTriangle } from 'lucide-react'
+import type { ReactNode } from 'react'
+import { ConfirmDestructiveDialog } from '@/components/ui/confirm-destructive-dialog'
interface DeleteDialogProps {
open: boolean
@@ -9,55 +7,35 @@ interface DeleteDialogProps {
onConfirm: () => void
onCancel: () => void
title: string
- description: React.ReactNode
+ description: ReactNode
itemName?: string
isDeleting?: boolean
}
-export function DeleteDialog({
- open,
- onOpenChange,
- onConfirm,
- onCancel,
- title,
- description,
+export function DeleteDialog({
+ open,
+ onOpenChange,
+ onConfirm,
+ onCancel,
+ title,
+ description,
itemName,
- isDeleting = false
+ isDeleting = false
}: DeleteDialogProps) {
return (
-
+ This will permanently delete "{itemName}". This action cannot be undone.>
+ ) : undefined}
+ confirmLabel={title.includes('Configuration') ? 'Delete Configuration' : 'Delete'}
+ pendingLabel="Deleting..."
+ isPending={isDeleting}
+ />
)
}
diff --git a/frontend/src/components/ui/dialog.test.tsx b/frontend/src/components/ui/dialog.test.tsx
index dad50a87..827afc7a 100644
--- a/frontend/src/components/ui/dialog.test.tsx
+++ b/frontend/src/components/ui/dialog.test.tsx
@@ -1,5 +1,5 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, act } from "@testing-library/react";
import {
Dialog,
DialogContent,
@@ -382,3 +382,87 @@ describe("DialogContent", () => {
});
});
});
+
+function stubVisualViewport(height: number) {
+ const listeners = new Set<() => void>()
+ Object.defineProperty(window, 'visualViewport', {
+ configurable: true,
+ writable: true,
+ value: {
+ height,
+ offsetTop: 0,
+ addEventListener: (_: string, fn: () => void) => listeners.add(fn),
+ removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
+ },
+ })
+ return listeners
+}
+
+describe('keyboardAware', () => {
+ const originalInnerHeight = window.innerHeight
+ const originalVisualViewport = Object.getOwnPropertyDescriptor(window, 'visualViewport')
+
+ afterEach(() => {
+ if (originalVisualViewport) {
+ Object.defineProperty(window, 'visualViewport', originalVisualViewport)
+ } else {
+ // @ts-expect-error allow delete
+ delete window.visualViewport
+ }
+ Object.defineProperty(window, 'innerHeight', {
+ writable: true,
+ configurable: true,
+ value: originalInnerHeight,
+ })
+ })
+
+ it('applies keyboard inset as bottom padding when keyboardAware and a text input is focused', () => {
+ window.innerHeight = 800
+ const listeners = stubVisualViewport(500)
+ render(
+
+ );
+ screen.getByTestId('dialog-input').focus()
+ act(() => {
+ listeners.forEach((fn) => fn())
+ })
+ const content = screen.getByTestId('dialog-content')
+ expect(content).toHaveStyle({ paddingBottom: '300px' })
+ })
+
+ it('does not set inline bottom padding when no keyboard is present', () => {
+ window.innerHeight = 800
+ const listeners = stubVisualViewport(800)
+ render(
+
+ );
+ screen.getByTestId('dialog-input').focus()
+ listeners.forEach((fn) => fn())
+ const content = screen.getByTestId('dialog-content')
+ expect(content.style.paddingBottom).toBe('')
+ })
+
+ it('does not set inline bottom padding when keyboardAware is omitted', () => {
+ window.innerHeight = 800
+ const listeners = stubVisualViewport(500)
+ render(
+
+ );
+ screen.getByTestId('dialog-input').focus()
+ listeners.forEach((fn) => fn())
+ const content = screen.getByTestId('dialog-content')
+ expect(content.style.paddingBottom).toBe('')
+ })
+});
diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx
index 693a06d2..819dbabd 100644
--- a/frontend/src/components/ui/dialog.tsx
+++ b/frontend/src/components/ui/dialog.tsx
@@ -4,6 +4,7 @@ import { X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useSwipeBack } from '@/hooks/useMobile'
+import { useVisualViewport } from '@/hooks/useVisualViewport'
const DialogOpenContext = React.createContext(true)
@@ -42,6 +43,7 @@ interface DialogContentProps
fullscreen?: boolean
mobileFullscreen?: boolean
mobileSwipeToClose?: boolean
+ keyboardAware?: boolean
canSwipeBack?: () => boolean
onSwipeBack?: () => void
onOpenChange?: (open: boolean) => void
@@ -51,17 +53,18 @@ interface DialogContentProps
const DialogContent = React.forwardRef<
React.ElementRef,
DialogContentProps
->(({ className, children, hideCloseButton, fullscreen, mobileFullscreen, mobileSwipeToClose, canSwipeBack, onSwipeBack, overlayClassName, style, ...props }, ref) => {
+>(({ className, children, hideCloseButton, fullscreen, mobileFullscreen, mobileSwipeToClose, keyboardAware, canSwipeBack, onSwipeBack, overlayClassName, style, ...props }, ref) => {
const isMobileFullscreenMode = fullscreen || mobileFullscreen
const isDialogOpen = React.useContext(DialogOpenContext)
const [isMobile, setIsMobile] = React.useState(() => typeof window !== 'undefined' ? window.innerWidth < 768 : false)
const shouldEnableMobileSwipe = mobileSwipeToClose !== false && isMobile && isDialogOpen
const shouldAnimateSwipe = shouldEnableMobileSwipe && isMobileFullscreenMode
- const swipeContainerRef = React.useRef(null)
+ const { keyboardHeight } = useVisualViewport({ enabled: keyboardAware === true && isDialogOpen })
+ const [swipeContainer, setSwipeContainer] = React.useState(null)
const closeTriggerRef = React.useRef(null)
const combinedRef = React.useCallback((node: HTMLDivElement | null) => {
- swipeContainerRef.current = node
+ setSwipeContainer(node)
if (typeof ref === 'function') {
ref(node)
} else if (ref) {
@@ -81,10 +84,10 @@ const DialogContent = React.forwardRef<
React.useEffect(() => {
if (shouldEnableMobileSwipe) {
- return swipeBind(swipeContainerRef.current)
+ return swipeBind(swipeContainer)
}
return undefined
- }, [shouldEnableMobileSwipe, swipeBind])
+ }, [shouldEnableMobileSwipe, swipeBind, swipeContainer])
const baseStyle = isMobileFullscreenMode
? { paddingTop: 'env(safe-area-inset-top, 0px)' }
@@ -94,6 +97,7 @@ const DialogContent = React.forwardRef<
...baseStyle,
...style,
...(shouldAnimateSwipe ? swipeStyles : undefined),
+ ...(keyboardAware && keyboardHeight > 0 ? { paddingBottom: `${keyboardHeight}px` } : undefined),
}
return (
diff --git a/frontend/src/components/ui/discard-dialog.tsx b/frontend/src/components/ui/discard-dialog.tsx
index e5344244..0a9ce2da 100644
--- a/frontend/src/components/ui/discard-dialog.tsx
+++ b/frontend/src/components/ui/discard-dialog.tsx
@@ -1,7 +1,4 @@
-import { Button } from '@/components/ui/button'
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Alert, AlertDescription } from '@/components/ui/alert'
-import { AlertTriangle } from 'lucide-react'
+import { ConfirmDestructiveDialog } from '@/components/ui/confirm-destructive-dialog'
interface DiscardDialogProps {
open: boolean
@@ -23,37 +20,17 @@ export function DiscardDialog({
const itemText = fileCount === 1 ? '1 file' : `${fileCount} files`
return (
-
+
)
}
diff --git a/frontend/src/components/ui/editor-find-bar.test.tsx b/frontend/src/components/ui/editor-find-bar.test.tsx
new file mode 100644
index 00000000..865dc078
--- /dev/null
+++ b/frontend/src/components/ui/editor-find-bar.test.tsx
@@ -0,0 +1,62 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { EditorFindBar } from './editor-find-bar'
+
+const baseProps = {
+ query: '',
+ onQueryChange: vi.fn(),
+ matchCount: 0,
+ currentMatch: 0,
+ onPrev: vi.fn(),
+ onNext: vi.fn(),
+ inputName: 'config-find',
+}
+
+describe('EditorFindBar', () => {
+ it('hides the match counter when the query is empty', () => {
+ render()
+ expect(screen.queryByTestId('find-match-count')).not.toBeInTheDocument()
+ })
+
+ it('reports the current match position', () => {
+ render()
+ expect(screen.getByTestId('find-match-count')).toHaveTextContent('2 of 3')
+ })
+
+ it('reports zero matches', () => {
+ render()
+ expect(screen.getByTestId('find-match-count')).toHaveTextContent('0 matches')
+ })
+
+ it('disables navigation when there are no matches', () => {
+ render()
+ expect(screen.getByRole('button', { name: 'Previous match' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'Next match' })).toBeDisabled()
+ })
+
+ it('advances on Enter and goes back on Shift+Enter', async () => {
+ const onNext = vi.fn()
+ const onPrev = vi.fn()
+ const user = userEvent.setup()
+ render()
+ const input = screen.getByRole('textbox', { name: 'Find in content' })
+ await user.click(input)
+ await user.keyboard('{Enter}')
+ expect(onNext).toHaveBeenCalledTimes(1)
+ await user.keyboard('{Shift>}{Enter}{/Shift}')
+ expect(onPrev).toHaveBeenCalledTimes(1)
+ })
+
+ it('sizes navigation controls for touch on mobile', () => {
+ render()
+ const next = screen.getByRole('button', { name: 'Next match' })
+ expect(next.className).toContain('size-10')
+ expect(next.className).toContain('md:size-8')
+ })
+
+ it('uses a 16px input on mobile to prevent iOS zoom', () => {
+ render()
+ expect(screen.getByRole('textbox', { name: 'Find in content' }).className).toContain('text-[16px]')
+ })
+})
diff --git a/frontend/src/components/ui/editor-find-bar.tsx b/frontend/src/components/ui/editor-find-bar.tsx
new file mode 100644
index 00000000..ee1d7ea4
--- /dev/null
+++ b/frontend/src/components/ui/editor-find-bar.tsx
@@ -0,0 +1,83 @@
+import { Search, ChevronUp, ChevronDown } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { cn } from '@/lib/utils'
+
+interface EditorFindBarProps {
+ query: string
+ onQueryChange: (query: string) => void
+ matchCount: number
+ currentMatch: number
+ onPrev: () => void
+ onNext: () => void
+ inputName: string
+ placeholder?: string
+ className?: string
+}
+
+export function EditorFindBar({
+ query,
+ onQueryChange,
+ matchCount,
+ currentMatch,
+ onPrev,
+ onNext,
+ inputName,
+ placeholder = 'Find in content...',
+ className,
+}: EditorFindBarProps) {
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ if (e.shiftKey) onPrev()
+ else onNext()
+ }
+ }
+
+ const noMatches = matchCount === 0
+
+ return (
+
+
+
+ onQueryChange(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ aria-label="Find in content"
+ autoComplete="off"
+ name={inputName}
+ className="pl-9 h-10 md:h-9 text-[16px] md:text-sm"
+ />
+
+ {query && (
+
+ {matchCount > 0 ? `${currentMatch} of ${matchCount}` : '0 matches'}
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/components/ui/unsaved-changes-dialog.tsx b/frontend/src/components/ui/unsaved-changes-dialog.tsx
new file mode 100644
index 00000000..b344d0ae
--- /dev/null
+++ b/frontend/src/components/ui/unsaved-changes-dialog.tsx
@@ -0,0 +1,31 @@
+import { ConfirmDestructiveDialog } from '@/components/ui/confirm-destructive-dialog'
+
+interface UnsavedChangesDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onDiscard: () => void
+ onKeepEditing: () => void
+ itemName?: string
+}
+
+export function UnsavedChangesDialog({
+ open,
+ onOpenChange,
+ onDiscard,
+ onKeepEditing,
+ itemName,
+}: UnsavedChangesDialogProps) {
+ return (
+
+ )
+}
diff --git a/frontend/src/hooks/useMobile.ts b/frontend/src/hooks/useMobile.ts
index cc234dd1..345f1a23 100644
--- a/frontend/src/hooks/useMobile.ts
+++ b/frontend/src/hooks/useMobile.ts
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, type CSSProperties } from 're
import { useSwipeNavigation } from '@/contexts/SwipeNavigationContext'
export function useMobile() {
- const [isMobile, setIsMobile] = useState(false)
+ const [isMobile, setIsMobile] = useState(() => (typeof window !== 'undefined' ? window.innerWidth < 768 : false))
useEffect(() => {
const checkMobile = () => {
diff --git a/frontend/src/hooks/useVisualViewport.test.tsx b/frontend/src/hooks/useVisualViewport.test.tsx
new file mode 100644
index 00000000..d319ed41
--- /dev/null
+++ b/frontend/src/hooks/useVisualViewport.test.tsx
@@ -0,0 +1,124 @@
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { act, renderHook } from '@testing-library/react'
+import { useVisualViewport } from './useVisualViewport'
+
+interface StubViewport {
+ height: number
+ offsetTop: number
+ listeners: Set<() => void>
+}
+
+function stubVisualViewport(height: number): StubViewport {
+ const listeners = new Set<() => void>()
+ Object.defineProperty(window, 'visualViewport', {
+ configurable: true,
+ writable: true,
+ value: {
+ height,
+ offsetTop: 0,
+ addEventListener: (_: string, fn: () => void) => listeners.add(fn),
+ removeEventListener: (_: string, fn: () => void) => listeners.delete(fn),
+ },
+ })
+ return { height, offsetTop: 0, listeners }
+}
+
+function createInput(id: string): HTMLInputElement {
+ const input = document.createElement('input')
+ input.id = id
+ document.body.appendChild(input)
+ return input
+}
+
+describe('useVisualViewport', () => {
+ const originalVisualViewport = Object.getOwnPropertyDescriptor(window, 'visualViewport')
+ const originalInnerHeight = window.innerHeight
+
+ afterEach(() => {
+ if (originalVisualViewport) {
+ Object.defineProperty(window, 'visualViewport', originalVisualViewport)
+ } else {
+ // @ts-expect-error allow delete
+ delete window.visualViewport
+ }
+ Object.defineProperty(window, 'innerHeight', {
+ writable: true,
+ configurable: true,
+ value: originalInnerHeight,
+ })
+ document.body.innerHTML = ''
+ })
+
+ it('returns 0 when disabled', () => {
+ stubVisualViewport(500)
+ const { result } = renderHook(() => useVisualViewport({ enabled: false }))
+ expect(result.current.keyboardHeight).toBe(0)
+ })
+
+ it('reports the keyboard height when a text input is focused', () => {
+ window.innerHeight = 800
+ const viewport = stubVisualViewport(500)
+ const input = createInput('a')
+ const { result } = renderHook(() => useVisualViewport())
+ act(() => {
+ input.focus()
+ viewport.listeners.forEach((fn) => fn())
+ })
+ expect(result.current.keyboardHeight).toBe(300)
+ })
+
+ it('preserves the keyboard inset when focus transfers between text inputs', () => {
+ window.innerHeight = 800
+ const viewport = stubVisualViewport(500)
+ const first = createInput('a')
+ const second = createInput('b')
+ const { result } = renderHook(() => useVisualViewport())
+
+ act(() => {
+ first.focus()
+ viewport.listeners.forEach((fn) => fn())
+ })
+ expect(result.current.keyboardHeight).toBe(300)
+
+ act(() => {
+ second.focus()
+ })
+ expect(result.current.keyboardHeight).toBe(300)
+ })
+
+ it('drops the keyboard inset when focus leaves all text inputs', () => {
+ window.innerHeight = 800
+ const viewport = stubVisualViewport(500)
+ const input = createInput('a')
+ const { result } = renderHook(() => useVisualViewport())
+
+ act(() => {
+ input.focus()
+ viewport.listeners.forEach((fn) => fn())
+ })
+ expect(result.current.keyboardHeight).toBe(300)
+
+ act(() => {
+ input.blur()
+ })
+ expect(result.current.keyboardHeight).toBe(0)
+ })
+
+ it('does not subscribe to visualViewport when disabled', () => {
+ const viewport = stubVisualViewport(500)
+ const addEventListener = vi.fn()
+ Object.defineProperty(window, 'visualViewport', {
+ configurable: true,
+ writable: true,
+ value: {
+ height: 500,
+ offsetTop: 0,
+ addEventListener,
+ removeEventListener: vi.fn(),
+ },
+ })
+ renderHook(() => useVisualViewport({ enabled: false }))
+ expect(addEventListener).not.toHaveBeenCalled()
+ void viewport
+ })
+})
diff --git a/frontend/src/hooks/useVisualViewport.ts b/frontend/src/hooks/useVisualViewport.ts
index 95617b8e..8454830a 100644
--- a/frontend/src/hooks/useVisualViewport.ts
+++ b/frontend/src/hooks/useVisualViewport.ts
@@ -7,10 +7,18 @@ const isTextInputFocused = () => {
return tag === 'TEXTAREA' || tag === 'INPUT' || (el as HTMLElement).isContentEditable
}
-export function useVisualViewport() {
+interface UseVisualViewportOptions {
+ enabled?: boolean
+}
+
+export function useVisualViewport({ enabled = true }: UseVisualViewportOptions = {}) {
const [keyboardHeight, setKeyboardHeight] = useState(0)
useEffect(() => {
+ if (!enabled) {
+ setKeyboardHeight(0)
+ return
+ }
const viewport = window.visualViewport
if (!viewport) return
@@ -27,6 +35,7 @@ export function useVisualViewport() {
viewport.addEventListener('resize', update)
viewport.addEventListener('scroll', update)
+ window.addEventListener('focusin', update)
window.addEventListener('focusout', update)
window.addEventListener('pageshow', update)
document.addEventListener('visibilitychange', update)
@@ -35,11 +44,12 @@ export function useVisualViewport() {
return () => {
viewport.removeEventListener('resize', update)
viewport.removeEventListener('scroll', update)
+ window.removeEventListener('focusin', update)
window.removeEventListener('focusout', update)
window.removeEventListener('pageshow', update)
document.removeEventListener('visibilitychange', update)
}
- }, [])
+ }, [enabled])
return { keyboardHeight }
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 38598837..42b3f4cc 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -414,24 +414,3 @@ input:-webkit-autofill:active {
.animate-progress {
animation: progress 2s ease-in-out infinite;
}
-
-/* Error line highlighting - red-orange selection color */
-.error-highlight::selection {
- background-color: #fecaca;
- color: #991b1b;
-}
-
-.dark .error-highlight::selection {
- background-color: #7f1d1d;
- color: #fecaca;
-}
-
-.error-highlight::-moz-selection {
- background-color: #fecaca;
- color: #991b1b;
-}
-
-.dark .error-highlight::-moz-selection {
- background-color: #7f1d1d;
- color: #fecaca;
-}
diff --git a/frontend/src/lib/editorScroll.test.ts b/frontend/src/lib/editorScroll.test.ts
new file mode 100644
index 00000000..cd3a7c3d
--- /dev/null
+++ b/frontend/src/lib/editorScroll.test.ts
@@ -0,0 +1,24 @@
+import { describe, it, expect } from 'vitest'
+import { computeScrollTopForRow } from './editorScroll'
+
+describe('computeScrollTopForRow', () => {
+ it('centres the row in the viewport', () => {
+ expect(computeScrollTopForRow({ rowTop: 500, rowHeight: 24, viewportHeight: 200, maxScrollTop: 1000 })).toBe(412)
+ })
+
+ it('clamps to zero when the row is near the top', () => {
+ expect(computeScrollTopForRow({ rowTop: 8, rowHeight: 24, viewportHeight: 400, maxScrollTop: 1000 })).toBe(0)
+ })
+
+ it('clamps to maxScrollTop when the row is near the end', () => {
+ expect(computeScrollTopForRow({ rowTop: 980, rowHeight: 24, viewportHeight: 200, maxScrollTop: 800 })).toBe(800)
+ })
+
+ it('returns zero when the content is shorter than the viewport', () => {
+ expect(computeScrollTopForRow({ rowTop: 48, rowHeight: 24, viewportHeight: 400, maxScrollTop: 0 })).toBe(0)
+ })
+
+ it('tolerates an unmeasured viewport', () => {
+ expect(computeScrollTopForRow({ rowTop: 100, rowHeight: 0, viewportHeight: 0, maxScrollTop: 0 })).toBe(0)
+ })
+})
diff --git a/frontend/src/lib/editorScroll.ts b/frontend/src/lib/editorScroll.ts
new file mode 100644
index 00000000..de93774d
--- /dev/null
+++ b/frontend/src/lib/editorScroll.ts
@@ -0,0 +1,12 @@
+interface RowScrollInput {
+ rowTop: number
+ rowHeight: number
+ viewportHeight: number
+ maxScrollTop: number
+}
+
+export function computeScrollTopForRow({ rowTop, rowHeight, viewportHeight, maxScrollTop }: RowScrollInput): number {
+ const centred = rowTop - viewportHeight / 2 + rowHeight / 2
+ const upperBound = Math.max(0, maxScrollTop)
+ return Math.min(Math.max(0, centred), upperBound)
+}
diff --git a/frontend/src/lib/jsonc.test.ts b/frontend/src/lib/jsonc.test.ts
new file mode 100644
index 00000000..c3662fb4
--- /dev/null
+++ b/frontend/src/lib/jsonc.test.ts
@@ -0,0 +1,80 @@
+import { describe, it, expect } from 'vitest'
+import { parseJsonc, parseJsoncErrorLine, resolveJsoncIssueLine } from './jsonc'
+
+const CONFIG = `{
+ "$schema": "https://opencode.ai/config.json",
+ "theme": "system",
+ "model": "anthropic/claude-sonnet-4",
+ "provider": {
+ "anthropic": {
+ "options": {
+ "apiKey": "sk-test"
+ }
+ }
+ }
+}`
+
+describe('parseJsoncErrorLine', () => {
+ it('extracts the line from a parseJsonc SyntaxError', () => {
+ let line: number | null = null
+ try {
+ parseJsonc('{\n "a": 1,\n "b" 2\n}')
+ } catch (error) {
+ line = parseJsoncErrorLine(error)
+ }
+ expect(line).toBe(3)
+ })
+
+ it('returns null for a non-syntax error', () => {
+ expect(parseJsoncErrorLine(new Error('network down'))).toBeNull()
+ })
+
+ it('returns null for a syntax error with no location', () => {
+ expect(parseJsoncErrorLine(new SyntaxError('Invalid JSONC'))).toBeNull()
+ })
+})
+
+describe('resolveJsoncIssueLine', () => {
+ it('resolves a top-level property to its line', () => {
+ expect(resolveJsoncIssueLine(CONFIG, 'theme')).toBe(3)
+ })
+
+ it('resolves a nested property path', () => {
+ expect(resolveJsoncIssueLine(CONFIG, 'provider.anthropic.options.apiKey')).toBe(8)
+ })
+
+ it('resolves a structured property path containing a dotted object key', () => {
+ const dotted = `{
+ "provider": {
+ "api.example.com": {
+ "key": "x"
+ }
+ }
+}`
+ expect(resolveJsoncIssueLine(dotted, ['provider', 'api.example.com', 'key'])).toBe(4)
+ })
+
+ it('resolves a structured array index path', () => {
+ expect(resolveJsoncIssueLine('{\n "tools": [\n "a",\n "b"\n ]\n}', ['tools', 1])).toBe(4)
+ })
+
+ it('returns null for an unknown structured path', () => {
+ expect(resolveJsoncIssueLine(CONFIG, ['provider', 'anthropic', 'missing'])).toBeNull()
+ })
+
+ it('resolves an array index segment', () => {
+ expect(resolveJsoncIssueLine('{\n "tools": [\n "a",\n "b"\n ]\n}', 'tools.1')).toBe(4)
+ })
+
+ it('returns null for an unknown path', () => {
+ expect(resolveJsoncIssueLine(CONFIG, 'nope.missing')).toBeNull()
+ })
+
+ it('returns null for the synthetic root path', () => {
+ expect(resolveJsoncIssueLine(CONFIG, 'root')).toBeNull()
+ })
+
+ it('returns null when the content cannot be parsed into a tree', () => {
+ expect(resolveJsoncIssueLine('', 'theme')).toBeNull()
+ })
+})
diff --git a/frontend/src/lib/jsonc.ts b/frontend/src/lib/jsonc.ts
index 02d48205..8c54d925 100644
--- a/frontend/src/lib/jsonc.ts
+++ b/frontend/src/lib/jsonc.ts
@@ -1,4 +1,12 @@
-export { parseJsonc } from '@opencode-manager/shared/utils'
+export { parseJsonc, parseJsoncErrorLine } from '@opencode-manager/shared/utils'
+import { findJsoncLineForPath, parseJsoncPathSegments } from '@opencode-manager/shared/utils'
+
+export function resolveJsoncIssueLine(content: string, path: PropertyKey[] | string): number | null {
+ const segments = Array.isArray(path)
+ ? path.map((segment) => (typeof segment === 'number' ? segment : String(segment)))
+ : parseJsoncPathSegments(path)
+ return findJsoncLineForPath(content, segments)
+}
export function hasJsoncComments(content: string): boolean {
return content.split('\n').some(line => {
diff --git a/frontend/src/lib/useFindInText.ts b/frontend/src/lib/useFindInText.ts
index f3e68acd..15d7edee 100644
--- a/frontend/src/lib/useFindInText.ts
+++ b/frontend/src/lib/useFindInText.ts
@@ -16,12 +16,14 @@ interface UseFindInTextReturn {
clear: () => void
}
+const EMPTY_MATCHES: FindMatch[] = []
+
export function useFindInText(text: string): UseFindInTextReturn {
const [query, setQueryState] = useState('')
const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
const matches = useMemo(() => {
- if (!query.trim()) return []
+ if (!query.trim()) return EMPTY_MATCHES
const results: FindMatch[] = []
const lowerText = text.toLowerCase()
const lowerQuery = query.toLowerCase()
diff --git a/shared/src/utils/jsonc.ts b/shared/src/utils/jsonc.ts
index 6d3f5f89..a858855b 100644
--- a/shared/src/utils/jsonc.ts
+++ b/shared/src/utils/jsonc.ts
@@ -1,4 +1,4 @@
-import { parse as parseJsoncLib, type ParseError } from 'jsonc-parser'
+import { parse as parseJsoncLib, parseTree, findNodeAtLocation, type ParseError, type JSONPath } from 'jsonc-parser'
export function parseJsonc(content: string): T {
const errors: ParseError[] = []
@@ -40,6 +40,26 @@ function getErrorMessageByCode(error: number): string {
8: 'Unexpected end of input',
9: 'Invalid comment',
}
-
+
return errorMessages[error] || 'Invalid JSONC'
}
+
+export function parseJsoncErrorLine(error: unknown): number | null {
+ if (!(error instanceof SyntaxError)) return null
+ const match = error.message.match(/at line (\d+)/)
+ return match ? Number(match[1]) : null
+}
+
+export function findJsoncLineForPath(content: string, path: JSONPath): number | null {
+ if (path.length === 0) return null
+ const root = parseTree(content, [], { allowTrailingComma: true, disallowComments: false })
+ if (!root) return null
+ const node = findNodeAtLocation(root, path)
+ if (!node) return null
+ return content.substring(0, node.offset).split('\n').length
+}
+
+export function parseJsoncPathSegments(path: string): JSONPath {
+ if (!path || path === 'root') return []
+ return path.split('.').map((segment) => (/^\d+$/.test(segment) ? Number(segment) : segment))
+}