Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
71 changes: 71 additions & 0 deletions src/runtime/bracket-matcher.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
62 changes: 62 additions & 0 deletions src/runtime/bracket-matcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { tokenize } from './tokenizer';

const OPEN_TO_CLOSE: Record<string, string> = { '(': ')', '[': ']', '{': '}' };
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<number, number>();
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];
}
11 changes: 11 additions & 0 deletions src/runtime/styles/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 21 additions & 2 deletions src/runtime/syntax-highlighter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -24,14 +25,32 @@ function escapeHtml(unsafe: string): string {
* Renders a token stream as HTML with a <span> per highlighted token, for
* use as a display layer behind a transparent <input> (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 += `<span class="${TOKEN_CLASS.BRACKET_MATCH}">${escapeHtml(token.value)}</span>`;
return;
}

switch (token.type) {
case 'string':
html += `<span class="${TOKEN_CLASS.STRING}">${escapeHtml(token.value)}</span>`;
Expand Down
24 changes: 20 additions & 4 deletions src/runtime/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -760,7 +768,7 @@ function setupREPL(
const val = input.value;

autosize();
updateHighlight(val);
updateHighlight();

// Pre-evaluation preview
const result = repl.preEvaluate(val);
Expand Down Expand Up @@ -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);
}
4 changes: 2 additions & 2 deletions website/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
Expand Down
10 changes: 5 additions & 5 deletions website/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading