From 424149a578c39616e23c20cd38f60ee5102ed13f Mon Sep 17 00:00:00 2001 From: Yukina Date: Sun, 21 Jun 2026 09:50:31 +0700 Subject: [PATCH 1/3] feat: highlight matched filter in grid --- core/ui/modules/grid-render.js | 8 ++--- core/ui/modules/utils.js | 40 ++++++++++++++++++++++++ core/ui/viewer.css | 7 +++++ core/ui/viewer.html | 20 ++++++------ scripts/build.mjs | 15 +++++---- website/public/sqlite-viewer/viewer.html | 18 +++++------ 6 files changed, 79 insertions(+), 29 deletions(-) diff --git a/core/ui/modules/grid-render.js b/core/ui/modules/grid-render.js index 89fc1167..8abec378 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 } from './utils.js'; import { getRowId, getCellValue } from './data-utils.js'; import { syncSelectionDOM } from './grid-selection.js'; @@ -175,9 +175,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, [state.filterQuery, state.columnFilters[col.name]]); td.appendChild(textSpan); if (hasContent) { diff --git a/core/ui/modules/utils.js b/core/ui/modules/utils.js index 710dcd43..46ee7759 100644 --- a/core/ui/modules/utils.js +++ b/core/ui/modules/utils.js @@ -15,6 +15,46 @@ export function escapeHtml(str) { .replace(/'/g, '''); } +/** + * Escape a string for safe use inside a RegExp pattern. + */ +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Append text to a parent element, wrapping any case-insensitive matches of + * the given terms in spans. Uses DOM text nodes + * (never innerHTML) so untrusted cell content can never be interpreted as markup. + */ +export function appendHighlightedText(parentEl, text, terms) { + const activeTerms = (terms || []).filter(t => t && t.trim()); + if (activeTerms.length === 0) { + parentEl.appendChild(document.createTextNode(text)); + return; + } + + const pattern = activeTerms.map(t => escapeRegExp(t.trim())).join('|'); + const regex = new RegExp(`(${pattern})`, 'gi'); + + let lastIndex = 0; + let match; + while ((match = regex.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) regex.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 87b386de..f7898e33 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 f79b7b9e..96aa9d9f 100644 --- a/core/ui/viewer.html +++ b/core/ui/viewer.html @@ -6,7 +6,7 @@ SQLite Explorer @@ -364,12 +364,12 @@ diff --git a/scripts/build.mjs b/scripts/build.mjs index 0041dbca..7199ecae 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 3b3caa1d..417e9d19 100644 --- a/website/public/sqlite-viewer/viewer.html +++ b/website/public/sqlite-viewer/viewer.html @@ -6,7 +6,7 @@ SQLite Explorer @@ -364,7 +364,7 @@ From 8b1a8f4b07a955e36e2e7052a6725986d655de86 Mon Sep 17 00:00:00 2001 From: Yukina Date: Sun, 21 Jun 2026 10:34:25 +0700 Subject: [PATCH 2/3] perf: precompute highlight matcher per column instead of per cell --- core/ui/modules/grid-render.js | 10 +++++-- core/ui/modules/utils.js | 36 ++++++++++++++++-------- core/ui/viewer.html | 26 ++++++++--------- website/public/sqlite-viewer/viewer.html | 24 ++++++++-------- 4 files changed, 58 insertions(+), 38 deletions(-) diff --git a/core/ui/modules/grid-render.js b/core/ui/modules/grid-render.js index 8abec378..60f0b11a 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, appendHighlightedText } 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) { @@ -177,7 +183,7 @@ function createTableBody(orderedColumns, columnIndexMap, pinnedColumnOffsets, ro textSpan.className = 'cell-text'; // Use DOM text nodes (never innerHTML) for security (prevents XSS). // formatCellValueAsText returns unescaped text suitable for textContent/text nodes. - appendHighlightedText(textSpan, displayValue, [state.filterQuery, state.columnFilters[col.name]]); + 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 46ee7759..7a06d41d 100644 --- a/core/ui/modules/utils.js +++ b/core/ui/modules/utils.js @@ -23,23 +23,37 @@ function escapeRegExp(str) { } /** - * Append text to a parent element, wrapping any case-insensitive matches of - * the given terms in spans. Uses DOM text nodes - * (never innerHTML) so untrusted cell content can never be interpreted as markup. + * 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. */ -export function appendHighlightedText(parentEl, text, terms) { - const activeTerms = (terms || []).filter(t => t && t.trim()); - if (activeTerms.length === 0) { +export function buildHighlightMatcher(terms) { + const escaped = []; + for (const t of terms) { + const trimmed = t && t.trim(); + if (trimmed) escaped.push(escapeRegExp(trimmed)); + } + if (escaped.length === 0) return null; + return new RegExp(`(${escaped.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; } - const pattern = activeTerms.map(t => escapeRegExp(t.trim())).join('|'); - const regex = new RegExp(`(${pattern})`, 'gi'); - + // The matcher is shared across cells; reset its state before scanning. + matcher.lastIndex = 0; let lastIndex = 0; let match; - while ((match = regex.exec(text)) !== null) { + while ((match = matcher.exec(text)) !== null) { if (match.index > lastIndex) { parentEl.appendChild(document.createTextNode(text.slice(lastIndex, match.index))); } @@ -48,7 +62,7 @@ export function appendHighlightedText(parentEl, text, terms) { mark.textContent = match[0]; parentEl.appendChild(mark); lastIndex = match.index + match[0].length; - if (match[0].length === 0) regex.lastIndex++; // guard against zero-length matches + if (match[0].length === 0) matcher.lastIndex++; // guard against zero-length matches } if (lastIndex < text.length) { parentEl.appendChild(document.createTextNode(text.slice(lastIndex))); diff --git a/core/ui/viewer.html b/core/ui/viewer.html index 96aa9d9f..27bc88fd 100644 --- a/core/ui/viewer.html +++ b/core/ui/viewer.html @@ -364,51 +364,51 @@ diff --git a/website/public/sqlite-viewer/viewer.html b/website/public/sqlite-viewer/viewer.html index 417e9d19..661b1c25 100644 --- a/website/public/sqlite-viewer/viewer.html +++ b/website/public/sqlite-viewer/viewer.html @@ -364,45 +364,45 @@ From b508c4e08d402949c71650e367ded0d6953ee72e Mon Sep 17 00:00:00 2001 From: zknpr Date: Sun, 21 Jun 2026 22:52:12 +0200 Subject: [PATCH 3/3] fix: prevent regex prefix-shadowing in highlight matcher (Gemini) buildHighlightMatcher joined active filter terms into a single alternation in arbitrary order. Regex alternation is first-match, so a shorter term that is a prefix of a longer one (e.g. global filter "cat" + a column filter "category") would shadow the longer match and only highlight "cat". De-duplicate the terms and sort them longest-first so the longest applicable term wins. Regenerated bundles. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/ui/modules/utils.js | 14 ++++++++++---- core/ui/viewer.html | 2 +- website/public/sqlite-viewer/viewer.html | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/core/ui/modules/utils.js b/core/ui/modules/utils.js index 7a06d41d..50a4ff09 100644 --- a/core/ui/modules/utils.js +++ b/core/ui/modules/utils.js @@ -26,15 +26,21 @@ function escapeRegExp(str) { * 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 escaped = []; + const seen = new Set(); for (const t of terms) { const trimmed = t && t.trim(); - if (trimmed) escaped.push(escapeRegExp(trimmed)); + if (trimmed) seen.add(trimmed); } - if (escaped.length === 0) return null; - return new RegExp(`(${escaped.join('|')})`, 'gi'); + if (seen.size === 0) return null; + const ordered = [...seen].sort((a, b) => b.length - a.length); + return new RegExp(`(${ordered.map(escapeRegExp).join('|')})`, 'gi'); } /** diff --git a/core/ui/viewer.html b/core/ui/viewer.html index 27bc88fd..a7c5a423 100644 --- a/core/ui/viewer.html +++ b/core/ui/viewer.html @@ -364,7 +364,7 @@