diff --git a/core/ui/modules/grid-render.js b/core/ui/modules/grid-render.js index 89fc116..60f0b11 100644 --- a/core/ui/modules/grid-render.js +++ b/core/ui/modules/grid-render.js @@ -1,5 +1,5 @@ import { state } from './state.js'; -import { escapeHtml, formatCellValueAsText } from './utils.js'; +import { escapeHtml, formatCellValueAsText, appendHighlightedText, buildHighlightMatcher } from './utils.js'; import { getRowId, getCellValue } from './data-utils.js'; import { syncSelectionDOM } from './grid-selection.js'; @@ -106,6 +106,12 @@ function createTableBody(orderedColumns, columnIndexMap, pinnedColumnOffsets, ro ...state.gridData.map((row, idx) => ({ idx, rowId: getRowId(row, idx) })).filter(r => !state.pinnedRowIds.has(r.rowId)) ]; + // Precompute one highlight matcher per column (depends on the global filter + + // that column's filter, not on the row), so we don't rebuild a RegExp per cell. + const columnMatchers = orderedColumns.map(col => + buildHighlightMatcher([state.filterQuery, state.columnFilters[col.name]]) + ); + const fragment = document.createDocumentFragment(); for (const { idx: rowIdx, rowId } of orderedRowIndices) { @@ -175,9 +181,9 @@ function createTableBody(orderedColumns, columnIndexMap, pinnedColumnOffsets, ro const textSpan = document.createElement('span'); textSpan.className = 'cell-text'; - // Use textContent for security (prevents XSS). - // formatCellValueAsText returns unescaped text suitable for textContent. - textSpan.textContent = displayValue; + // Use DOM text nodes (never innerHTML) for security (prevents XSS). + // formatCellValueAsText returns unescaped text suitable for textContent/text nodes. + appendHighlightedText(textSpan, displayValue, columnMatchers[displayColIdx]); td.appendChild(textSpan); if (hasContent) { diff --git a/core/ui/modules/utils.js b/core/ui/modules/utils.js index 710dcd4..50a4ff0 100644 --- a/core/ui/modules/utils.js +++ b/core/ui/modules/utils.js @@ -15,6 +15,66 @@ export function escapeHtml(str) { .replace(/'/g, '''); } +/** + * Escape a string for safe use inside a RegExp pattern. + */ +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Build a reusable case-insensitive RegExp that matches any of the given filter + * terms, or null when there are no active terms. Compile this once per column + * and reuse the matcher across every cell rather than rebuilding it per cell. + * + * Terms are de-duplicated and sorted longest-first: regex alternation matches + * the first listed alternative that fits, so without this a shorter term that is + * a prefix of a longer one (e.g. "cat" vs "category") would shadow the longer + * match and only highlight the prefix. + */ +export function buildHighlightMatcher(terms) { + const seen = new Set(); + for (const t of terms) { + const trimmed = t && t.trim(); + if (trimmed) seen.add(trimmed); + } + if (seen.size === 0) return null; + const ordered = [...seen].sort((a, b) => b.length - a.length); + return new RegExp(`(${ordered.map(escapeRegExp).join('|')})`, 'gi'); +} + +/** + * Append text to a parent element, wrapping matches of a precompiled `matcher` + * (from buildHighlightMatcher) in spans. Uses DOM + * text nodes (never innerHTML) so untrusted cell content can never be interpreted + * as markup. When `matcher` is null, the text is appended verbatim (fast path). + */ +export function appendHighlightedText(parentEl, text, matcher) { + if (!matcher) { + parentEl.appendChild(document.createTextNode(text)); + return; + } + + // The matcher is shared across cells; reset its state before scanning. + matcher.lastIndex = 0; + let lastIndex = 0; + let match; + while ((match = matcher.exec(text)) !== null) { + if (match.index > lastIndex) { + parentEl.appendChild(document.createTextNode(text.slice(lastIndex, match.index))); + } + const mark = document.createElement('mark'); + mark.className = 'cell-highlight'; + mark.textContent = match[0]; + parentEl.appendChild(mark); + lastIndex = match.index + match[0].length; + if (match[0].length === 0) matcher.lastIndex++; // guard against zero-length matches + } + if (lastIndex < text.length) { + parentEl.appendChild(document.createTextNode(text.slice(lastIndex))); + } +} + /** * Validate and sanitize a rowid for use in SQL queries. */ diff --git a/core/ui/viewer.css b/core/ui/viewer.css index 87b386d..f7898e3 100644 --- a/core/ui/viewer.css +++ b/core/ui/viewer.css @@ -1117,6 +1117,13 @@ body { white-space: nowrap; } +/* Light background for substrings matching the active filter/search term */ +.cell-highlight { + background: var(--vscode-editor-findMatchHighlightBackground, rgba(234, 182, 42, 0.4)); + color: inherit; + border-radius: 2px; +} + /* Expand icon - hidden by default, shown on hover when cell has overflow */ .data-cell .expand-icon { display: none; diff --git a/core/ui/viewer.html b/core/ui/viewer.html index f79b7b9..a7c5a42 100644 --- a/core/ui/viewer.html +++ b/core/ui/viewer.html @@ -6,7 +6,7 @@ SQLite Explorer @@ -364,51 +364,51 @@ diff --git a/scripts/build.mjs b/scripts/build.mjs index 0041dbc..7199eca 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -286,10 +286,13 @@ const bundleWebview = async () => { } } - // Bundle: replace placeholders with actual content + // Bundle: replace placeholders with actual content. + // Use function replacers so literal "$&"/"$1"-style sequences inside the + // bundled JS/CSS (e.g. regex replacement strings) aren't reinterpreted as + // String.replace() substitution patterns. const bundled = template - .replace('', finalCss) - .replace('', finalJs); + .replace('', () => finalCss) + .replace('', () => finalJs); // Write the bundled HTML fs.writeFileSync(outputPath, bundled, 'utf-8'); @@ -379,9 +382,9 @@ const bundleWebDemoViewer = async () => { const codiconLink = ''; const bundled = template - .replace('', codiconLink) - .replace('', finalCss) - .replace('', finalJs) + .replace('', () => codiconLink) + .replace('', () => finalCss) + .replace('', () => finalJs) .replace('nonce=""', ''); // Remove nonce for static web demo // Write the bundled HTML diff --git a/website/public/sqlite-viewer/viewer.html b/website/public/sqlite-viewer/viewer.html index 3b3caa1..c5a946c 100644 --- a/website/public/sqlite-viewer/viewer.html +++ b/website/public/sqlite-viewer/viewer.html @@ -6,7 +6,7 @@ SQLite Explorer @@ -364,45 +364,45 @@