diff --git a/package.json b/package.json index 39e52e5..9ef5c22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codehacks/virtual-console", - "version": "0.7.9-alpha.0", + "version": "0.7.9-alpha.1", "packageManager": "pnpm@11.25.0", "description": "DevTools-style console for the places that don't have DevTools — iOS WebViews, Android WebViews, any page where console.log goes nowhere.", "type": "module", diff --git a/src/runtime/bracket-matcher.test.ts b/src/runtime/bracket-matcher.test.ts new file mode 100644 index 0000000..8889dc6 --- /dev/null +++ b/src/runtime/bracket-matcher.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { findMatchingBrackets } from './bracket-matcher'; + +describe('findMatchingBrackets', () => { + it('matches a simple pair with the caret right after the opener', () => { + // "(a)" - caret after "(" (index 1) + expect(findMatchingBrackets('(a)', 1)).toEqual([0, 2]); + }); + + it('matches a simple pair with the caret right before the opener', () => { + // "(a)" - caret before "(" (index 0), nothing precedes it + expect(findMatchingBrackets('(a)', 0)).toEqual([0, 2]); + }); + + it('matches with the caret right before the closer', () => { + // "(a)" - caret before ")" (index 2) + expect(findMatchingBrackets('(a)', 2)).toEqual([0, 2]); + }); + + it('matches with the caret right after the closer', () => { + // "(a)" - caret after ")" (index 3) + expect(findMatchingBrackets('(a)', 3)).toEqual([0, 2]); + }); + + it('prefers the bracket behind the caret when two sit back-to-back with no space', () => { + const code = '[{ONE:{a:1},TWO:[1,2]}]'; + // 0123456789... + + // Caret between the inner object's "}" (21) and the outer array's + // "]" (22): the character immediately behind the caret ("}") wins, + // so this should match the outer object's braces, not the array. + expect(findMatchingBrackets(code, 22)).toEqual([1, 21]); + + // Caret after the final "]" (23): behind the caret is "]" (22), + // which matches the array's opening "[" at index 0. + expect(findMatchingBrackets(code, 23)).toEqual([0, 22]); + }); + + it('matches the same pair from either side when brackets are separated by whitespace', () => { + const code = '[ { } ]'; + // 0123456 + + // Caret just after "{" (index 3) and just before "{" (index 2) are + // both "touching" the same bracket once whitespace separates it + // from its neighbor, so the side doesn't change the result. + expect(findMatchingBrackets(code, 2)).toEqual([2, 4]); + expect(findMatchingBrackets(code, 3)).toEqual([2, 4]); + }); + + it('returns null when the caret is not adjacent to any bracket', () => { + expect(findMatchingBrackets('( a )', 2)).toBeNull(); + }); + + it('returns null for an unmatched (unbalanced) bracket', () => { + expect(findMatchingBrackets('(a', 1)).toBeNull(); + }); + + it('returns null for mismatched bracket types instead of pairing them', () => { + // "(]" - the "(" and "]" never match, so neither is highlighted. + expect(findMatchingBrackets('(]', 1)).toBeNull(); + expect(findMatchingBrackets('(]', 2)).toBeNull(); + }); + + it('ignores brackets inside a string literal', () => { + expect(findMatchingBrackets('"(a)"', 2)).toBeNull(); + }); + + it('returns null for empty input', () => { + expect(findMatchingBrackets('', 0)).toBeNull(); + }); +}); diff --git a/src/runtime/bracket-matcher.ts b/src/runtime/bracket-matcher.ts new file mode 100644 index 0000000..1a89e5e --- /dev/null +++ b/src/runtime/bracket-matcher.ts @@ -0,0 +1,62 @@ +import { tokenize } from './tokenizer'; + +const OPEN_TO_CLOSE: Record = { '(': ')', '[': ']', '{': '}' }; +const OPENERS = new Set(Object.keys(OPEN_TO_CLOSE)); +const CLOSERS = new Set(Object.values(OPEN_TO_CLOSE)); + +interface BracketToken { + char: string; + pos: number; +} + +/** + * Finds the character offsets of the bracket pair that should be highlighted + * for a given cursor position, mirroring Chrome DevTools / editor behavior: + * only punctuation-token brackets count (so `"{"` inside a string is never a + * candidate - tokenize() already separates those out), and matching runs + * left-to-right with a stack so mismatched closers are simply left unpaired + * rather than corrupting the rest of the match. + * + * Which bracket is "active" for the cursor resolves the ambiguity of + * back-to-back brackets with no whitespace between them (`}]`): the + * character immediately *before* the cursor wins over the one immediately + * after. When whitespace separates brackets, only one side is ever actually + * touching a bracket character, so this priority never changes the outcome - + * it only matters for the tightly-packed case. + */ +export function findMatchingBrackets(code: string, cursorPos: number): [number, number] | null { + const brackets: BracketToken[] = []; + let offset = 0; + for (const token of tokenize(code)) { + if (token.type === 'punctuation' && (OPENERS.has(token.value) || CLOSERS.has(token.value))) { + brackets.push({ char: token.value, pos: offset }); + } + offset += token.value.length; + } + + if (brackets.length === 0) return null; + + const matches = new Map(); + const stack: BracketToken[] = []; + for (const bracket of brackets) { + if (OPENERS.has(bracket.char)) { + stack.push(bracket); + continue; + } + const top = stack[stack.length - 1]; + if (top && OPEN_TO_CLOSE[top.char] === bracket.char) { + stack.pop(); + matches.set(top.pos, bracket.pos); + matches.set(bracket.pos, top.pos); + } + } + + const findAt = (pos: number) => brackets.find((b) => b.pos === pos); + const active = findAt(cursorPos - 1) ?? findAt(cursorPos); + if (!active) return null; + + const matchPos = matches.get(active.pos); + if (matchPos === undefined) return null; + + return active.pos < matchPos ? [active.pos, matchPos] : [matchPos, active.pos]; +} diff --git a/src/runtime/styles/base.css b/src/runtime/styles/base.css index 87ccaed..06f3f2d 100644 --- a/src/runtime/styles/base.css +++ b/src/runtime/styles/base.css @@ -506,6 +506,17 @@ color: var(--vc-comment); } +/* Matching bracket pair in the REPL input - subtle background plus a small + underline, like DevTools/editor bracket-match highlighting. Uses the same + theme-agnostic gray overlay as the rest of this file (see the various + rgba(128, 128, 128, ...) rules above) instead of a per-theme variable, + since every theme's text stays legible over a neutral gray at low alpha. */ +.vc-bracket-match { + background: rgba(128, 128, 128, 0.25); + border-bottom: 1px solid rgba(128, 128, 128, 0.7); + border-radius: 2px; +} + .vc-property-key { margin-right: 6px; color: var(--vc-property-key); diff --git a/src/runtime/syntax-highlighter.ts b/src/runtime/syntax-highlighter.ts index 0703caf..23f7535 100644 --- a/src/runtime/syntax-highlighter.ts +++ b/src/runtime/syntax-highlighter.ts @@ -8,7 +8,8 @@ const TOKEN_CLASS = { BOOLEAN: 'vc-boolean', COMMENT: 'vc-comment', OPERATOR: 'vc-operator', - FUNCTION: 'vc-function' + FUNCTION: 'vc-function', + BRACKET_MATCH: 'vc-bracket-match' }; function escapeHtml(unsafe: string): string { @@ -24,14 +25,32 @@ function escapeHtml(unsafe: string): string { * Renders a token stream as HTML with a per highlighted token, for * use as a display layer behind a transparent (so it can't be run * through a full parser/AST). + * + * `matchBrackets`, when given, is a pair of character offsets (from + * findMatchingBrackets) identifying the two bracket punctuation tokens to + * render with the matched-pair style instead of their normal plain + * passthrough. */ -export function highlightCode(code: string): string { +export function highlightCode(code: string, matchBrackets?: [number, number] | null): string { if (!code) return ''; const tokens = tokenize(code); let html = ''; + let offset = 0; tokens.forEach((token: Token, index: number) => { + const tokenStart = offset; + offset += token.value.length; + + if ( + token.type === 'punctuation' && + matchBrackets && + (tokenStart === matchBrackets[0] || tokenStart === matchBrackets[1]) + ) { + html += `${escapeHtml(token.value)}`; + return; + } + switch (token.type) { case 'string': html += `${escapeHtml(token.value)}`; diff --git a/src/runtime/ui.ts b/src/runtime/ui.ts index 5a60ff7..57135f1 100644 --- a/src/runtime/ui.ts +++ b/src/runtime/ui.ts @@ -5,6 +5,7 @@ import type { IconName } from './icons'; import { createObjectViewer } from './object-viewer'; import { repl } from './repl'; import { highlightCode } from './syntax-highlighter'; +import { findMatchingBrackets } from './bracket-matcher'; import { getStorageItem, setStorageItem, STORAGE_KEYS } from './storage'; import { cycleTheme, getThemeConfig, initThemeIndex } from './theme'; import type { LogType } from './types'; @@ -668,9 +669,16 @@ function setupREPL( } }); - // Debounced so retyping fast on mobile doesn't re-tokenize on every keystroke - const updateHighlight = debounce((code: string) => { - highlightBackdrop.innerHTML = highlightCode(code); + // Debounced so retyping fast on mobile doesn't re-tokenize on every + // keystroke. Reads selection state live (not via captured args) so a + // trailing call always reflects where the caret ended up, even after a + // programmatic value change followed by an async caret move (see the + // ArrowUp/ArrowDown history handling below). + const updateHighlight = debounce(() => { + const cursorPos = input.selectionStart ?? 0; + const hasSelection = cursorPos !== (input.selectionEnd ?? cursorPos); + const matchBrackets = hasSelection ? null : findMatchingBrackets(input.value, cursorPos); + highlightBackdrop.innerHTML = highlightCode(input.value, matchBrackets); }, 50); // Grows the textarea to fit its content (up to the CSS max-height, past @@ -760,7 +768,7 @@ function setupREPL( const val = input.value; autosize(); - updateHighlight(val); + updateHighlight(); // Pre-evaluation preview const result = repl.preEvaluate(val); @@ -811,4 +819,12 @@ function setupREPL( suggestionsBox.style.display = 'none'; }, 200); }); + + // Bracket matching depends on caret position, not just text content, so + // it also needs to refresh on pure caret movement - a click to + // reposition, arrow-key navigation, or a mouse/keyboard text selection - + // none of which fire 'input'. + input.addEventListener('click', updateHighlight); + input.addEventListener('keyup', updateHighlight); + input.addEventListener('select', updateHighlight); } diff --git a/website/package.json b/website/package.json index 74cb042..8e6e585 100644 --- a/website/package.json +++ b/website/package.json @@ -1,7 +1,7 @@ { "name": "virtual-console-website", "private": true, - "version": "0.1.4", + "version": "0.1.5-alpha.0", "type": "module", "scripts": { "dev": "vite", @@ -12,7 +12,7 @@ "clean": "rm -rf dist dist-ssr .vite" }, "dependencies": { - "@codehacks/virtual-console": "0.7.9-alpha.0", + "@codehacks/virtual-console": "0.7.9-alpha.1", "lucide-react": "^1.39.0", "react": "^19.2.8", "react-dom": "^19.2.8" diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml index 2e6c156..b0e3ded 100644 --- a/website/pnpm-lock.yaml +++ b/website/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@codehacks/virtual-console': - specifier: 0.7.9-alpha.0 - version: 0.7.9-alpha.0(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) + specifier: 0.7.9-alpha.1 + version: 0.7.9-alpha.1(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) lucide-react: specifier: ^1.39.0 version: 1.39.0(react@19.2.8) @@ -48,8 +48,8 @@ importers: packages: - '@codehacks/virtual-console@0.7.9-alpha.0': - resolution: {integrity: sha512-17KbQoaWbYc+ZtJ2UyWI6QU1kmtoddhEfXeuiqzR7nQRKKP2sLQJIKrI8kKdqjaQ6LWpWjI+qVGs6Zr34kFJQg==} + '@codehacks/virtual-console@0.7.9-alpha.1': + resolution: {integrity: sha512-hGMaQt0k+57bued+S9ylN8TNF/UW59q0ed2atWFzkEC57K4s7cw2tQ4h7PUj82Kjnbk58C0D7dRpyvzKtTAS5A==} engines: {node: '>=26'} peerDependencies: vite: '>=5.0.0 <9.0.0' @@ -705,7 +705,7 @@ packages: snapshots: - '@codehacks/virtual-console@0.7.9-alpha.0(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0))': + '@codehacks/virtual-console@0.7.9-alpha.1(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0))': optionalDependencies: vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0)