From faabbe3fd2ac886e8f4530cec5547298a938ec92 Mon Sep 17 00:00:00 2001
From: Vuong <3168632+vuon9@users.noreply.github.com>
Date: Wed, 11 Feb 2026 23:04:43 +0700
Subject: [PATCH 1/2] feat: enhance RegExpTester with live highlighting and
improved UX
Major improvements to the RegExp Tester tool:
- Live match highlighting in test string with layered textarea+backdrop
- Color-coded capture groups (8 distinct colors) in both test string and match details
- Hover tooltips showing match index, groups, and captures
- Auto-expanding regex input (1-10 lines) with theme-aware scrollbar
- Unified input group styling with visual connection between pattern/flags/copy button
- Flags popover accessible via clicking flags input
- Premium regex syntax highlighting for better readability
- Filtered match details to show only captured groups with values
- Copy button to copy full regex pattern (/pattern/flags)
UI/UX:
- Reduced spacing throughout for more compact layout
- Consistent color scheme using rgba values for better visibility
- Scrollbar only appears when content exceeds max height
- Removed separate highlighted matches section (integrated into live view)
Technical:
- Removed unused Settings icon import
- Fixed syntax error (extra closing div tag)
- Updated TOOL_STATUS.md with completion date
Closes: RegExpTester enhancement request
---
TOOL_STATUS.md | 2 +-
frontend/src/pages/RegExpTester.jsx | 1044 +++++++++++++++++++++------
2 files changed, 807 insertions(+), 239 deletions(-)
diff --git a/TOOL_STATUS.md b/TOOL_STATUS.md
index b53e049..5d664a4 100644
--- a/TOOL_STATUS.md
+++ b/TOOL_STATUS.md
@@ -23,7 +23,7 @@ This document tracks the refactoring and development status of each tool compone
| **CodeFormatter** | 🟢 Done | Unified code formatting tool supporting JSON (with jq filters), XML (with XPath), HTML (with CSS selectors), SQL, CSS, and JavaScript. Features: format/minify modes, filter/query support for structured data, auto-format on input change, persistent state. Backend: Go with gojq library for jq support. Replaces: JsonFormatter, SqlFormatter | Completed 2026-01-31 |
| **ColorConverter** | 🟢 Done | Comprehensive color conversion tool with visual picker and eyedropper support. Features: 11 programming languages (CSS, Swift, .NET, Java, Android, Obj-C, Flutter, Unity, React Native, OpenGL, SVG), 5 color formats (HEX, RGB, HSL, HSV, CMYK), color history with 10 recent colors, random color generator, copy-to-clipboard for all code snippets. Uses Carbon Tabs for language selection. | Completed 2026-02-01 |
| **CronJobParser** | 🟢 Done | Refactored to follow Carbon Design System. Features: Split-pane layout, 8 common examples in clickable tiles, real-time parsing, large centered output display, layout toggle. | Completed 2026-01-31 |
-| **RegExpTester** | 🟡 In Progress | Refactored with improved UI. Features: Flag toggle tags (g, i, m, s, u, y), split-pane layout, match count in output label, error display with styling, layout toggle. | Updated 2026-01-31 |
+| **RegExpTester** | 🟢 Done | Enhanced with live highlighting. Features: Unified regex input group with visual connection, expandable auto-resizing textarea (1-10 lines), theme-aware scrollbar, flags popover accessible via flags input, live match highlighting with group colors in test string and match details, hover tooltips, split-pane layout, copy full regex button, layout toggle. | Completed 2026-02-11 |
| **TextDiffChecker** | 🟡 In Progress | Refactored with enhanced features. Features: Diff mode switcher (Lines/Words/Chars), auto-compare on input change, Clear button, improved diff view with color coding, layout toggle. | Updated 2026-01-31 |
| **DateTimeConverter** | 🟢 Done | Complete redesign as unified DateTime Converter. All features on single screen - no tabs. Client-side only (no backend dependency). Features: Auto-detect input format (Unix timestamps: s/ms/μs/ns, ISO dates, SQL dates, US/EU formats), Quick presets (Now, Start/End of Day, Tomorrow, Yesterday, Next Week, Unix Epoch), Output format selector (ISO, RFC, SQL, US, EU, Compact), Timezone support, Main result display with relative time, All formats grid with copy buttons, Toggle-able sections: Visual Widgets (Calendar + Analog Clock), Time Calculator (Date A vs B with delta), Batch Converter (multi-line input with table results), Timezone Comparison (6 major cities), History persistence (localStorage, last 20), URL share support (?ts=). Unified, user-friendly interface designed for real-world datetime conversion needs. | Completed 2026-02-01 |
diff --git a/frontend/src/pages/RegExpTester.jsx b/frontend/src/pages/RegExpTester.jsx
index 6bcda49..cf55c1e 100644
--- a/frontend/src/pages/RegExpTester.jsx
+++ b/frontend/src/pages/RegExpTester.jsx
@@ -1,6 +1,7 @@
-import React, { useState, useEffect } from 'react';
-import { TextInput, Tag } from '@carbon/react';
-import { ToolHeader, ToolControls, ToolPane, ToolSplitPane } from '../components/ToolUI';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { TextInput, CopyButton } from '@carbon/react';
+import { ChevronDown } from '@carbon/icons-react';
+import { ToolHeader, ToolSplitPane } from '../components/ToolUI';
import useLayoutToggle from '../hooks/useLayoutToggle';
const FLAG_OPTIONS = [
@@ -12,41 +13,161 @@ const FLAG_OPTIONS = [
{ flag: 'y', label: 'Sticky', desc: 'Match from lastIndex' },
];
-const HighlightedText = ({ text, regex, flags }) => {
- if (!regex || !text) return {text} ;
+// Escape HTML special characters to prevent XSS and rendering issues
+const escapeHtml = (text) => {
+ if (!text) return '';
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+};
+
+const GROUP_COLORS = [
+ { bg: 'rgba(66, 190, 101, 0.15)', border: 'rgba(66, 190, 101, 0.3)', text: 'var(--cds-text-primary)' }, // Green (full match)
+ { bg: 'rgba(69, 137, 245, 0.15)', border: 'rgba(69, 137, 245, 0.3)', text: 'var(--cds-text-primary)' }, // Blue
+ { bg: 'rgba(241, 194, 27, 0.15)', border: 'rgba(241, 194, 27, 0.3)', text: 'var(--cds-text-primary)' }, // Yellow
+ { bg: 'rgba(218, 30, 40, 0.15)', border: 'rgba(218, 30, 40, 0.3)', text: 'var(--cds-text-primary)' }, // Red
+ { bg: 'rgba(165, 110, 255, 0.15)', border: 'rgba(165, 110, 255, 0.3)', text: 'var(--cds-text-primary)' }, // Purple
+ { bg: 'rgba(0, 189, 186, 0.15)', border: 'rgba(0, 189, 186, 0.3)', text: 'var(--cds-text-primary)' }, // Teal
+ { bg: 'rgba(255, 118, 178, 0.15)', border: 'rgba(255, 118, 178, 0.3)', text: 'var(--cds-text-primary)' }, // Pink
+ { bg: 'rgba(255, 140, 0, 0.15)', border: 'rgba(255, 140, 0, 0.3)', text: 'var(--cds-text-primary)' }, // Orange
+];
+
+// Premium Regex Syntax Highlighter
+const highlightRegex = (regexStr) => {
+ if (!regexStr) return '';
+
+ const tokenRegex = /(\\.)|(\[(?:\\.|[^\]])*\])|(\((?:\\.|[^\)])*\))|([\*\+\?]\??|\{\d+(,\d*)?\}\??)|([\^$\|])/g;
+
+ let lastIndex = 0;
+ let result = '';
+ let match;
+
+ while ((match = tokenRegex.exec(regexStr)) !== null) {
+ // Add text before match
+ result += escapeHtml(regexStr.slice(lastIndex, match.index));
+
+ const [full, escaped, charClass, group, quantifier, operator] = match;
+
+ if (escaped) {
+ result += `${escapeHtml(escaped)} `;
+ } else if (charClass) {
+ result += `${escapeHtml(charClass)} `;
+ } else if (group) {
+ result += `${escapeHtml(group)} `;
+ } else if (quantifier) {
+ result += `${escapeHtml(quantifier)} `;
+ } else if (operator) {
+ result += `${escapeHtml(operator)} `;
+ }
+
+ lastIndex = tokenRegex.lastIndex;
+ }
+
+ result += escapeHtml(regexStr.slice(lastIndex));
+ return result;
+};
+
+// Generate highlighted HTML with match data attributes for tooltips and group colors
+const generateHighlightedHtml = (text, regex, flags) => {
+ if (!regex || !text) return escapeHtml(text);
try {
const re = new RegExp(regex, flags.includes('g') ? flags : flags + 'g');
- const parts = [];
+ let result = '';
let lastIndex = 0;
let match;
+ let matchCount = 0;
while ((match = re.exec(text)) !== null) {
// Add text before match
if (match.index > lastIndex) {
- parts.push(
- {text.slice(lastIndex, match.index)}
+ result += escapeHtml(text.slice(lastIndex, match.index));
+ }
+
+ matchCount++;
+ const matchIndex = match.index;
+ const fullMatchText = match[0];
+
+ // Build tooltip data
+ let tooltipData = `Match #${matchCount} at index ${matchIndex}`;
+ if (match.groups && Object.keys(match.groups).length > 0) {
+ const groups = Object.entries(match.groups)
+ .map(([k, v]) => `${k}: "${escapeHtml(v || '')}"`)
+ .join('\n');
+ tooltipData += `\nGroups:\n${groups}`;
+ }
+ if (match.length > 1) {
+ const unnamedGroups = match.slice(1).filter((g, idx) =>
+ !match.groups || !Object.values(match.groups).includes(g)
);
+ if (unnamedGroups.length > 0) {
+ const groups = unnamedGroups
+ .map((g, i) => `${i + 1}: "${escapeHtml(g || '')}"`)
+ .join('\n');
+ tooltipData += `\nCaptures:\n${groups}`;
+ }
}
- // Add highlighted match
- parts.push(
-
- {match[0]}
-
- );
+ // If there are capture groups, render them with different colors
+ if (match.length > 1) {
+ // Find all group positions in the match
+ const groupPositions = [];
+ let currentPos = 0;
+
+ for (let i = 1; i < match.length; i++) {
+ const groupText = match[i];
+ if (groupText !== undefined) {
+ const groupIndex = fullMatchText.indexOf(groupText, currentPos);
+ if (groupIndex !== -1) {
+ groupPositions.push({
+ index: groupIndex,
+ text: groupText,
+ groupNum: i
+ });
+ currentPos = groupIndex + groupText.length;
+ }
+ }
+ }
+
+ // Sort by position
+ groupPositions.sort((a, b) => a.index - b.index);
+
+ // Build the highlighted match with different colors for groups
+ let matchResult = '';
+ let matchPos = 0;
+ const fullMatchColor = GROUP_COLORS[0];
+
+ // Process the match text, applying different colors to groups
+ groupPositions.forEach((group) => {
+ // Add text before this group (part of full match)
+ if (group.index > matchPos) {
+ const beforeText = escapeHtml(fullMatchText.slice(matchPos, group.index));
+ matchResult += `${beforeText} `;
+ }
+
+ // Add the group with its color
+ const groupColor = GROUP_COLORS[group.groupNum % GROUP_COLORS.length] || GROUP_COLORS[1];
+ matchResult += `${escapeHtml(group.text)} `;
+
+ matchPos = group.index + group.text.length;
+ });
+
+ // Add remaining text after last group
+ if (matchPos < fullMatchText.length) {
+ const afterText = escapeHtml(fullMatchText.slice(matchPos));
+ matchResult += `${afterText} `;
+ }
+
+ result += matchResult;
+ } else {
+ // No groups - just highlight the full match
+ const fullMatchColor = GROUP_COLORS[0];
+ result += `${escapeHtml(fullMatchText)} `;
+ }
lastIndex = re.lastIndex;
-
+
// Prevent infinite loop for zero-length matches
if (match.index === re.lastIndex) {
re.lastIndex++;
@@ -55,25 +176,460 @@ const HighlightedText = ({ text, regex, flags }) => {
// Add remaining text
if (lastIndex < text.length) {
- parts.push(
- {text.slice(lastIndex)}
- );
+ result += escapeHtml(text.slice(lastIndex));
}
- return <>{parts}>;
+ return result;
} catch (e) {
- return {text} ;
+ return escapeHtml(text);
}
};
+// Live Highlighted Editor Component
+const LiveHighlightedEditor = ({ text, setText, regex, flags }) => {
+ const textareaRef = useRef(null);
+ const backdropRef = useRef(null);
+ const [highlightedHtml, setHighlightedHtml] = useState('');
+
+ // Update highlighting when text, regex, or flags change
+ useEffect(() => {
+ const html = generateHighlightedHtml(text, regex, flags);
+ setHighlightedHtml(html);
+ }, [text, regex, flags]);
+
+ // Sync scroll between textarea and backdrop
+ const handleScroll = useCallback(() => {
+ if (textareaRef.current && backdropRef.current) {
+ backdropRef.current.scrollTop = textareaRef.current.scrollTop;
+ backdropRef.current.scrollLeft = textareaRef.current.scrollLeft;
+ }
+ }, []);
+
+ return (
+
+ {/* Backdrop - shows highlighted text */}
+
' }}
+ />
+
+ {/* Textarea - for user input */}
+
+ );
+};
+
+// Expandable Regex Input Component with auto-resize and syntax highlighting
+const ExpandableRegexInput = ({ value, onChange, error }) => {
+ const textareaRef = useRef(null);
+ const backdropRef = useRef(null);
+ const [highlightedHtml, setHighlightedHtml] = useState('');
+
+ const LINE_HEIGHT = 20;
+ const MIN_LINES = 1;
+ const MAX_LINES = 10;
+ const MIN_HEIGHT = MIN_LINES * LINE_HEIGHT + 16;
+ const MAX_HEIGHT = MAX_LINES * LINE_HEIGHT + 16;
+
+ // Update highlighting
+ useEffect(() => {
+ setHighlightedHtml(highlightRegex(value));
+ }, [value]);
+
+ // Sync scroll
+ const handleScroll = useCallback(() => {
+ if (textareaRef.current && backdropRef.current) {
+ backdropRef.current.scrollTop = textareaRef.current.scrollTop;
+ backdropRef.current.scrollLeft = textareaRef.current.scrollLeft;
+ }
+ }, []);
+
+ // Auto-resize
+ useEffect(() => {
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto';
+ const scrollHeight = textareaRef.current.scrollHeight;
+ const newHeight = Math.min(Math.max(scrollHeight, MIN_HEIGHT), MAX_HEIGHT);
+ textareaRef.current.style.height = `${newHeight}px`;
+ }
+ }, [value]);
+
+ return (
+
+ {/* Syntax Highlighting Backdrop */}
+
' }}
+ />
+
+
+ );
+};
+
+// Flags Input Component
+const FlagsInputWithPopover = ({ flags, setFlags }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const containerRef = useRef(null);
+
+ const toggleFlag = (flag) => {
+ if (flags.includes(flag)) {
+ setFlags(flags.replace(flag, ''));
+ } else {
+ setFlags(flags + flag);
+ }
+ };
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (containerRef.current && !containerRef.current.contains(event.target)) {
+ setIsOpen(false);
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [isOpen]);
+
+ return (
+
+
setIsOpen(!isOpen)}
+ style={{
+ padding: '0.75rem 0.75rem 0',
+ height: '100%',
+ display: 'flex',
+ alignItems: 'flex-start',
+ cursor: 'pointer',
+ fontFamily: "'IBM Plex Mono', monospace",
+ fontSize: '0.875rem',
+ color: 'var(--cds-text-secondary)',
+ backgroundColor: isOpen ? 'var(--cds-layer-selected-01)' : 'transparent',
+ transition: 'all 0.2s ease',
+ userSelect: 'none',
+ borderLeft: '1px solid var(--cds-border-subtle)',
+ minWidth: '60px',
+ justifyContent: 'center',
+ lineHeight: '1.4'
+ }}
+ onMouseEnter={(e) => !isOpen && (e.currentTarget.style.backgroundColor = 'var(--cds-layer-hover-01)')}
+ onMouseLeave={(e) => !isOpen && (e.currentTarget.style.backgroundColor = 'transparent')}
+ >
+ {flags || flags }
+
+
+ {isOpen && (
+
+
+ Regex Flags
+
+
+ {FLAG_OPTIONS.map(({ flag, label, desc }) => (
+
toggleFlag(flag)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.75rem',
+ padding: '0.625rem 0.5rem',
+ cursor: 'pointer',
+ borderRadius: '2px',
+ backgroundColor: flags.includes(flag) ? 'var(--cds-layer-selected-01)' : 'transparent',
+ transition: 'all 0.15s',
+ }}
+ onMouseEnter={(e) => !flags.includes(flag) && (e.currentTarget.style.backgroundColor = 'var(--cds-layer-hover-01)')}
+ onMouseLeave={(e) => !flags.includes(flag) && (e.currentTarget.style.backgroundColor = 'transparent')}
+ >
+
+ {flag}
+
+
+
+ {label}
+
+
+ {desc}
+
+
+ {flags.includes(flag) && (
+
✓
+ )}
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+// Match Info Row Component
+const MatchInfoRow = ({ label, range, value, color, indent = false }) => (
+
+
+
+ {label}
+
+
+
+ {range}
+
+
+ {value}
+
+
+);
+
+// Tool Pane with Label Component
+const ToolPaneWithLabel = ({ label, children, action }) => (
+
+
+
+ {label}
+
+ {label === 'MATCH INFORMATION' ? (
+
+ ) : action}
+
+ {children}
+
+);
+
export default function RegExpTester() {
const [regexStr, setRegexStr] = useState('');
const [flags, setFlags] = useState('gm');
const [text, setText] = useState('');
- const [output, setOutput] = useState('');
+ const [output, setOutput] = useState([]);
const [error, setError] = useState('');
const [matches, setMatches] = useState([]);
+ // Add scrollbar styles
+ useEffect(() => {
+ const style = document.createElement('style');
+ style.textContent = `
+ .regex-input-scrollbar::-webkit-scrollbar {
+ width: 6px;
+ }
+ .regex-input-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+ margin: 2px 0;
+ }
+ .regex-input-scrollbar::-webkit-scrollbar-thumb {
+ background-color: var(--cds-border-subtle);
+ border-radius: 3px;
+ border: 1px solid transparent;
+ background-clip: padding-box;
+ }
+ .regex-input-scrollbar::-webkit-scrollbar-thumb:hover {
+ background-color: var(--cds-border-strong);
+ }
+ .regex-input-scrollbar::-webkit-scrollbar-corner {
+ background: transparent;
+ }
+ `;
+ document.head.appendChild(style);
+ return () => document.head.removeChild(style);
+ }, []);
+
const layout = useLayoutToggle({
toolKey: 'regexp-tester-layout',
defaultDirection: 'horizontal',
@@ -87,270 +643,282 @@ export default function RegExpTester() {
const runRegex = () => {
if (!regexStr) {
- setOutput('');
+ setOutput([]);
setError('');
setMatches([]);
return;
}
try {
+ const startTime = performance.now();
const re = new RegExp(regexStr, flags);
const foundMatches = Array.from(text.matchAll(re));
+ const endTime = performance.now();
+ const duration = (endTime - startTime).toFixed(2);
+
setMatches(foundMatches);
-
+
if (foundMatches.length === 0) {
- setOutput('No matches found.');
+ setOutput([
+
+ No matches found. ({duration}ms)
+
+ ]);
} else {
- const matchDetails = foundMatches.map((match, i) => {
- let detail = `Match ${i + 1}: "${match[0]}"\nIndex: ${match.index}`;
- if (match.groups) {
- const groupEntries = Object.entries(match.groups);
- if (groupEntries.length > 0) {
- detail += '\nGroups:\n' + groupEntries.map(([key, value]) => ` ${key}: "${value}"`).join('\n');
- }
- }
+ const matchRows = foundMatches.flatMap((match, i) => {
+ const rows = [];
+ const fullMatchColor = GROUP_COLORS[0];
+
+ rows.push(
+
+ );
+
if (match.length > 1) {
- const unnamedGroups = match.slice(1).filter((g, idx) => !Object.values(match.groups || {}).includes(g));
- if (unnamedGroups.length > 0) {
- detail += '\nUnnamed Groups:\n' + unnamedGroups.map((g, gi) => ` ${gi + 1}: "${g}"`).join('\n');
+ const fullMatchText = match[0];
+ let currentSearchPos = 0;
+
+ for (let gi = 1; gi < match.length; gi++) {
+ const groupText = match[gi];
+ if (groupText !== undefined && groupText !== '') {
+ const groupIndexInMatch = fullMatchText.indexOf(groupText, currentSearchPos);
+ if (groupIndexInMatch !== -1) {
+ const absoluteStart = match.index + groupIndexInMatch;
+ const absoluteEnd = absoluteStart + groupText.length;
+ const groupColor = GROUP_COLORS[gi % GROUP_COLORS.length] || GROUP_COLORS[1];
+
+ rows.push(
+
+ );
+ currentSearchPos = groupIndexInMatch + groupText.length;
+ }
+ }
}
}
- return detail;
- }).join('\n\n');
- setOutput(`Found ${foundMatches.length} match(es):\n\n${matchDetails}`);
+ return rows;
+ });
+
+ setOutput([
+
+ Found {foundMatches.length} match{foundMatches.length !== 1 ? 'es' : ''}:
+ {duration}ms
+
,
+
,
+
+ {matchRows}
+
+ ]);
}
setError('');
} catch (e) {
setError(e.message);
- setOutput('');
+ setOutput([]);
setMatches([]);
}
};
- const toggleFlag = (flag) => {
- if (flags.includes(flag)) {
- setFlags(flags.replace(flag, ''));
- } else {
- setFlags(flags + flag);
- }
+ const handleCopyFullRegex = () => {
+ const fullRegex = `/${regexStr}/${flags}`;
+ navigator.clipboard.writeText(fullRegex);
};
return (
-
-
+
-
-
-
+ {/* Prefix / */}
+
+
/
-
+
+
+
setRegexStr(e.target.value)}
- placeholder="Regular Expression..."
- invalid={!!error}
- style={{
- flex: 1,
- fontFamily: "'IBM Plex Mono', monospace"
- }}
+ onChange={setRegexStr}
+ error={error}
/>
-
+
+ {/* Suffix / */}
+
+ /
-
- setFlags(e.target.value)}
- placeholder="flags"
- style={{
- width: '80px',
- fontFamily: "'IBM Plex Mono', monospace"
- }}
+
+
+ {/* Flags Input */}
+
+
+ {/* Copy Button */}
+
+
-
-
-
- {FLAG_OPTIONS.map(({ flag, label }) => (
- toggleFlag(flag)}
- style={{ cursor: 'pointer' }}
- >
- {flag} - {label}
-
- ))}
+
+
-
+
+ {/* Error Display */}
{error && (
-
{error}
)}
-
-
-
- Test String
-
- {matches.length > 0 && (
-
- {matches.length} match{matches.length !== 1 ? 'es' : ''}
-
- )}
-
-
-
-
+ {/* Left Pane: Live Highlighted Input */}
+ 0 ? ` (${matches.length} match${matches.length !== 1 ? 'es' : ''})` : ''}`}
+ >
+
+
-
+ {/* Right Pane: Match Information */}
+
-
- Match Details
-
-
-
- {output || (
-
+ {output.length > 0 ? output : (
+
Matching results will appear here...
-
+
)}
-
+
-
- {text && regexStr && !error && (
-
-
- Highlighted Matches
-
-
-
-
-
- )}
);
}
From 32eccdd481484b36226f91383cfcdf59787260ce Mon Sep 17 00:00:00 2001
From: Vuong <3168632+vuon9@users.noreply.github.com>
Date: Wed, 11 Feb 2026 23:21:08 +0700
Subject: [PATCH 2/2] feat: add browser automation skill documentation and
enhance RegExpTester with improved regex highlighting, global flag handling,
and performance metric display.
---
.opencode/skill/browser-automation/SKILL.md | 50 +++++++++++++++++++++
frontend/src/pages/RegExpTester.jsx | 46 +++++++++----------
2 files changed, 73 insertions(+), 23 deletions(-)
create mode 100644 .opencode/skill/browser-automation/SKILL.md
diff --git a/.opencode/skill/browser-automation/SKILL.md b/.opencode/skill/browser-automation/SKILL.md
new file mode 100644
index 0000000..5a1aa8d
--- /dev/null
+++ b/.opencode/skill/browser-automation/SKILL.md
@@ -0,0 +1,50 @@
+---
+name: browser-automation
+description: Reliable, composable browser automation using minimal OpenCode Browser primitives.
+license: MIT
+compatibility: opencode
+metadata:
+ audience: agents
+ domain: browser
+---
+
+## What I do
+
+- Provide a safe, composable workflow for browsing tasks
+- Use `browser_query` list and index selection to click reliably
+- Confirm state changes after each action
+
+## Best-practice workflow
+
+1. Inspect tabs with `browser_get_tabs`
+2. Open new tabs with `browser_open_tab` when needed
+3. Navigate with `browser_navigate` if needed
+4. Wait for UI using `browser_query` with `timeoutMs`
+5. Discover candidates using `browser_query` with `mode=list`
+6. Click, type, or select using `index`
+7. Confirm using `browser_query` or `browser_snapshot`
+
+## Selecting options
+
+- Use `browser_select` for native `` elements
+- Prefer `value` or `label`; use `optionIndex` when needed
+- Example: `browser_select({ selector: "select", value: "plugin" })`
+
+## Query modes
+
+- `text`: read visible text from a matched element
+- `value`: read input values
+- `list`: list many matches with text/metadata
+- `exists`: check presence and count
+- `page_text`: extract visible page text
+
+## Opening tabs
+
+- Use `browser_open_tab` to create a new tab, optionally with `url` and `active`
+- Example: `browser_open_tab({ url: "https://example.com", active: false })`
+
+## Troubleshooting
+
+- If a selector fails, run `browser_query` with `mode=page_text` to confirm the content exists
+- Use `mode=list` on broad selectors (`button`, `a`, `*[role="button"]`) and choose by index
+- Confirm results after each action
diff --git a/frontend/src/pages/RegExpTester.jsx b/frontend/src/pages/RegExpTester.jsx
index cf55c1e..039ea9b 100644
--- a/frontend/src/pages/RegExpTester.jsx
+++ b/frontend/src/pages/RegExpTester.jsx
@@ -37,7 +37,8 @@ const GROUP_COLORS = [
const highlightRegex = (regexStr) => {
if (!regexStr) return '';
- const tokenRegex = /(\\.)|(\[(?:\\.|[^\]])*\])|(\((?:\\.|[^\)])*\))|([\*\+\?]\??|\{\d+(,\d*)?\}\??)|([\^$\|])/g;
+ // 1: escaped, 2: charClass, 3: group, 4: quantifier, 5: operator
+ const tokenRegex = /(\\.)|(\[(?:\\.|[^\]])*\])|(\((?:\\.|[^\)])*\))|([\*\+\?]\??|\{\d+(?:,\d*)?\}\??)|([\^$\|])/g;
let lastIndex = 0;
let result = '';
@@ -50,15 +51,15 @@ const highlightRegex = (regexStr) => {
const [full, escaped, charClass, group, quantifier, operator] = match;
if (escaped) {
- result += `${escapeHtml(escaped)} `;
+ result += `${escapeHtml(full)} `;
} else if (charClass) {
- result += `${escapeHtml(charClass)} `;
+ result += `${escapeHtml(full)} `;
} else if (group) {
- result += `${escapeHtml(group)} `;
+ result += `${escapeHtml(full)} `;
} else if (quantifier) {
- result += `${escapeHtml(quantifier)} `;
+ result += `${escapeHtml(full)} `;
} else if (operator) {
- result += `${escapeHtml(operator)} `;
+ result += `${escapeHtml(full)} `;
}
lastIndex = tokenRegex.lastIndex;
@@ -292,7 +293,7 @@ const LiveHighlightedEditor = ({ text, setText, regex, flags }) => {
const ExpandableRegexInput = ({ value, onChange, error }) => {
const textareaRef = useRef(null);
const backdropRef = useRef(null);
- const [highlightedHtml, setHighlightedHtml] = useState('');
+ const highlightedHtml = React.useMemo(() => highlightRegex(value), [value]);
const LINE_HEIGHT = 20;
const MIN_LINES = 1;
@@ -300,11 +301,6 @@ const ExpandableRegexInput = ({ value, onChange, error }) => {
const MIN_HEIGHT = MIN_LINES * LINE_HEIGHT + 16;
const MAX_HEIGHT = MAX_LINES * LINE_HEIGHT + 16;
- // Update highlighting
- useEffect(() => {
- setHighlightedHtml(highlightRegex(value));
- }, [value]);
-
// Sync scroll
const handleScroll = useCallback(() => {
if (textareaRef.current && backdropRef.current) {
@@ -367,11 +363,12 @@ const ExpandableRegexInput = ({ value, onChange, error }) => {
fontFamily: "'IBM Plex Mono', monospace",
fontSize: '0.875rem',
lineHeight: '1.4',
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-all',
resize: 'vertical',
outline: 'none',
overflowY: 'auto',
overflowX: 'hidden',
- wordBreak: 'break-all',
color: 'transparent',
caretColor: 'var(--cds-text-primary)',
display: 'block',
@@ -650,17 +647,18 @@ export default function RegExpTester() {
}
try {
const startTime = performance.now();
- const re = new RegExp(regexStr, flags);
+ const isGlobal = flags.includes('g');
+ const sanitizedFlags = isGlobal ? flags : flags + 'g';
+ const re = new RegExp(regexStr, sanitizedFlags);
const foundMatches = Array.from(text.matchAll(re));
- const endTime = performance.now();
- const duration = (endTime - startTime).toFixed(2);
-
setMatches(foundMatches);
if (foundMatches.length === 0) {
+ const endTime = performance.now();
+ const duration = (endTime - startTime).toFixed(3);
setOutput([
- No matches found. ({duration}ms)
+ No matches found. ({parseFloat(duration) < 0.001 ? '< 0.001' : duration}ms)
]);
} else {
@@ -709,6 +707,9 @@ export default function RegExpTester() {
return rows;
});
+ const endTime = performance.now();
+ const duration = (endTime - startTime).toFixed(3);
+
setOutput([
- Found {foundMatches.length} match{foundMatches.length !== 1 ? 'es' : ''}:
- {duration}ms
+ Found {foundMatches.length} match{foundMatches.length !== 1 ? 'es' : ''}
+
+ ({parseFloat(duration) < 0.001 ? '< 0.001' : duration}ms)
+
,