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
14 changes: 10 additions & 4 deletions core/ui/modules/grid-render.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Apply highlights in cell rewrite paths

This wires highlighting only into the full grid render, but cells are also rewritten by updateCellDom in core/ui/modules/edit.js, which still restores plain textContent. With an active filter, simply double-clicking a highlighted cell and pressing Escape goes through the cancel path and removes the highlight until a later full render, so matched cells become visually inconsistent; reuse the same highlighter when restoring edited cells.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Avoid highlighting synthetic cell placeholders

This scans the formatted display string for every cell, including synthetic text from formatCellValueAsText such as NULL, [BLOB], and the appended ... for long strings. When a global search matches the row via another column, queries like null, blob, or . can highlight these placeholders even though the SQL filter ran against the actual column values and did not match those cells; skip placeholder/generated text or match against the raw value before marking it.

Useful? React with 👍 / 👎.

td.appendChild(textSpan);

if (hasContent) {
Expand Down
60 changes: 60 additions & 0 deletions core/ui/modules/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard column filter terms before trimming

When a table has a column named like toString or constructor, state.columnFilters is still a plain {} with Object prototype, and the new grid-render caller passes state.columnFilters[col.name] into this helper even when no filter is set. In that case t can be an inherited function instead of a string, so calling trim() throws and the grid fails to render for that table; use an own-property lookup at the call site or a string type guard before trimming.

Useful? React with 👍 / 👎.

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');
}
Comment on lines +35 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When constructing a regular expression with alternations (e.g., (term1|term2)), shorter terms can shadow longer terms if they share a prefix and the shorter term appears first (for example, (cat|category) matching against 'category' will only match 'cat'). To prevent this prefix shadowing issue and avoid redundant duplicate terms, we should deduplicate the terms and sort them by length in descending order before joining them.

export function buildHighlightMatcher(terms) {
    const uniqueEscaped = new Set();
    for (const t of terms) {
        const trimmed = t && t.trim();
        if (trimmed) {
            uniqueEscaped.add(escapeRegExp(trimmed));
        }
    }
    if (uniqueEscaped.size === 0) return null;
    const sorted = Array.from(uniqueEscaped).sort((a, b) => b.length - a.length);
    return new RegExp('(' + sorted.join('|') + ')', 'gi');
}


/**
* Append text to a parent element, wrapping matches of a precompiled `matcher`
* (from buildHighlightMatcher) in <mark class="cell-highlight"> 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.
*/
Expand Down
7 changes: 7 additions & 0 deletions core/ui/viewer.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading