From 6f5253ea368b7c10a0b467a6b0702c783eac65ef Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:37:36 +0100 Subject: [PATCH 01/14] fix: disable scroll anchoring on tabulator virtual scroll container Prevents Chrome's scroll anchoring from double-compensating when the virtual DOM renderer adjusts padding during scroll-up, which caused visible content drift. --- log-viewer/src/tabulator/style/DataGrid.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/log-viewer/src/tabulator/style/DataGrid.scss b/log-viewer/src/tabulator/style/DataGrid.scss index 240b3afa..b298af5d 100644 --- a/log-viewer/src/tabulator/style/DataGrid.scss +++ b/log-viewer/src/tabulator/style/DataGrid.scss @@ -45,6 +45,7 @@ $footerActiveColor: #d00 !default; //footer bottom active text color .tabulator { .tabulator-tableholder { overflow-x: hidden; + overflow-anchor: none; .tabulator-table { display: block; background-color: default; From 8899958cd02cb0cb4f1508ff967899069281f7e1 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:40:56 +0100 Subject: [PATCH 02/14] perf: add CSS compositor hints for tabulator scroll performance will-change: transform promotes the scroll container to a compositor layer for GPU-accelerated scrolling. content-visibility: auto skips rendering off-screen table content. --- log-viewer/src/tabulator/style/DataGrid.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/log-viewer/src/tabulator/style/DataGrid.scss b/log-viewer/src/tabulator/style/DataGrid.scss index b298af5d..256b9fc2 100644 --- a/log-viewer/src/tabulator/style/DataGrid.scss +++ b/log-viewer/src/tabulator/style/DataGrid.scss @@ -46,9 +46,11 @@ $footerActiveColor: #d00 !default; //footer bottom active text color .tabulator-tableholder { overflow-x: hidden; overflow-anchor: none; + will-change: transform; .tabulator-table { display: block; background-color: default; + content-visibility: auto; } } From 0fd8cb1c76646319d497aad2966d8f113fc7d641 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:43:51 +0100 Subject: [PATCH 03/14] refactor: migrate find highlights to CSS Highlight API Replace DOM-based find/match highlighting (span insertion with .findMatch/.currentFindMatch classes) with the CSS Highlight API. Uses shared Highlight objects with per-instance Range tracking, rAF-throttled scroll listener for virtual scroll, and ::highlight() pseudo-elements for styling. --- .../analysis/components/AnalysisView.ts | 38 +- .../call-tree/components/CalltreeView.ts | 34 +- .../features/database/components/DMLView.ts | 28 +- .../features/database/components/SOQLView.ts | 25 +- log-viewer/src/styles/global.styles.ts | 13 +- log-viewer/src/tabulator/module/Find.ts | 362 ++++++++++-------- 6 files changed, 257 insertions(+), 243 deletions(-) diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index ea3cd4d9..d46f920d 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -24,7 +24,7 @@ import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js'; import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js'; import { GroupSort } from '../../../tabulator/groups/GroupSort.js'; import * as CommonModules from '../../../tabulator/module/CommonModules.js'; -import { Find, formatter } from '../../../tabulator/module/Find.js'; +import { Find } from '../../../tabulator/module/Find.js'; import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js'; import { RowNavigation } from '../../../tabulator/module/RowNavigation.js'; import dataGridStyles from '../../../tabulator/style/DataGrid.scss'; @@ -251,19 +251,12 @@ export class AnalysisView extends LitElement { return; } this.blockClearHighlights = true; - this.analysisTable?.blockRedraw(); const currentRow = this.findMap[this.findArgs.count]; - const rows = [ - currentRow, - this.findMap[this.findArgs.count + 1], - this.findMap[this.findArgs.count - 1], - ]; - rows.forEach((row) => { - row?.reformat(); + //@ts-expect-error This is a custom function added in by Find custom module + await this.analysisTable.setCurrentMatch(this.findArgs.count, currentRow, { + scrollIfVisible: false, + focusRow: false, }); - //@ts-expect-error This is a custom function added in by RowNavigation custom module - this.analysisTable.goToRow(currentRow, { scrollIfVisible: false, focusRow: false }); - this.analysisTable?.restoreRedraw(); this.blockClearHighlights = false; } @@ -321,9 +314,6 @@ export class AnalysisView extends LitElement { groupClosedShowCalcs: true, groupStartOpen: false, groupToggleElement: 'header', - rowFormatter: (row: RowComponent) => { - formatter(row, this.findArgs); - }, columnDefaults: { title: 'default', resizable: true, @@ -426,7 +416,21 @@ export class AnalysisView extends LitElement { ], }); - this.analysisTable.on('renderStarted', () => { + this.analysisTable.on('dataSorted', () => { + if (!this.blockClearHighlights && this.totalMatches > 0) { + this._resetFindWidget(); + this._clearSearchHighlights(); + } + }); + + this.analysisTable.on('dataFiltered', () => { + if (!this.blockClearHighlights && this.totalMatches > 0) { + this._resetFindWidget(); + this._clearSearchHighlights(); + } + }); + + this.analysisTable.on('dataGrouped', () => { if (!this.blockClearHighlights && this.totalMatches > 0) { this._resetFindWidget(); this._clearSearchHighlights(); @@ -442,7 +446,7 @@ export class AnalysisView extends LitElement { this.findArgs.text = ''; this.findArgs.count = 0; //@ts-expect-error This is a custom function added in by Find custom module - this.analysisTable.clearFindHighlights(Object.values(this.findMap)); + this.analysisTable.clearFindHighlights(); this.findMap = {}; this.totalMatches = 0; } diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index a7f31098..01e25142 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -24,7 +24,7 @@ import MinMaxFilter from '../../../tabulator/filters/MinMax.js'; import { progressFormatter } from '../../../tabulator/format/Progress.js'; import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js'; import * as CommonModules from '../../../tabulator/module/CommonModules.js'; -import { Find, formatter } from '../../../tabulator/module/Find.js'; +import { Find } from '../../../tabulator/module/Find.js'; import { MiddleRowFocus } from '../../../tabulator/module/MiddleRowFocus.js'; import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js'; import { RowNavigation } from '../../../tabulator/module/RowNavigation.js'; @@ -397,22 +397,12 @@ export class CalltreeView extends LitElement { return; } this.blockClearHighlights = true; - this.calltreeTable?.blockRedraw(); const currentRow = this.findMap[this.findArgs.count]; - const rows = [ - currentRow, - this.findMap[this.findArgs.count + 1], - this.findMap[this.findArgs.count - 1], - ]; - rows.forEach((row) => { - row?.reformat(); + //@ts-expect-error This is a custom function added in by Find custom module + await this.calltreeTable.setCurrentMatch(this.findArgs.count, currentRow, { + scrollIfVisible: false, + focusRow: false, }); - - if (currentRow) { - //@ts-expect-error This is a custom function added in by RowNavigation custom module - this.calltreeTable.goToRow(currentRow, { scrollIfVisible: false, focusRow: false }); - } - this.calltreeTable?.restoreRedraw(); this.blockClearHighlights = false; } @@ -610,9 +600,6 @@ export class CalltreeView extends LitElement { return "
"; } }, - rowFormatter: (row: RowComponent) => { - formatter(row, this.findArgs); - }, columnCalcs: 'both', columnDefaults: { title: 'default', @@ -867,7 +854,14 @@ export class CalltreeView extends LitElement { this.typeFilterCache.clear(); }); - this.calltreeTable.on('renderStarted', () => { + this.calltreeTable.on('dataSorted', () => { + if (!this.blockClearHighlights && this.totalMatches > 0) { + this._resetFindWidget(); + this._clearSearchHighlights(); + } + }); + + this.calltreeTable.on('dataFiltered', () => { if (!this.blockClearHighlights && this.totalMatches > 0) { this._resetFindWidget(); this._clearSearchHighlights(); @@ -899,7 +893,7 @@ export class CalltreeView extends LitElement { this.findArgs.text = ''; this.findArgs.count = 0; //@ts-expect-error This is a custom function added in by Find custom module - this.calltreeTable.clearFindHighlights(Object.values(this.findMap)); + this.calltreeTable.clearFindHighlights(); this.findMap = {}; this.totalMatches = 0; } diff --git a/log-viewer/src/features/database/components/DMLView.ts b/log-viewer/src/features/database/components/DMLView.ts index 19d551a2..c921b46f 100644 --- a/log-viewer/src/features/database/components/DMLView.ts +++ b/log-viewer/src/features/database/components/DMLView.ts @@ -21,7 +21,7 @@ import Number from '../../../tabulator/format/Number.js'; import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js'; import { GroupSort } from '../../../tabulator/groups/GroupSort.js'; import * as CommonModules from '../../../tabulator/module/CommonModules.js'; -import { Find, formatter } from '../../../tabulator/module/Find.js'; +import { Find } from '../../../tabulator/module/Find.js'; import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js'; import { RowNavigation } from '../../../tabulator/module/RowNavigation.js'; import dataGridStyles from '../../../tabulator/style/DataGrid.scss'; @@ -69,6 +69,7 @@ export class DMLView extends LitElement { document.addEventListener('lv-find', this._findEvt); document.addEventListener('lv-find-close', this._findEvt); + document.addEventListener('lv-find-match', this._findEvt); } updated(changedProperties: PropertyValues): void { @@ -201,24 +202,19 @@ export class DMLView extends LitElement { } // todo: fix search on grouped data - _highlightMatches(highlightIndex: number) { + async _highlightMatches(highlightIndex: number) { if (!this.dmlTable?.element?.clientHeight) { return; } this.findArgs.count = highlightIndex; const currentRow = this.findMap[highlightIndex]; - const rows = [currentRow, this.findMap[this.oldIndex]]; this.blockClearHighlights = true; - this.dmlTable.blockRedraw(); - rows.forEach((row) => { - row?.reformat(); + //@ts-expect-error This is a custom function added in by Find custom module + await this.dmlTable.setCurrentMatch(highlightIndex, currentRow, { + scrollIfVisible: false, + focusRow: false, }); - if (currentRow) { - //@ts-expect-error This is a custom function added in by RowNavigation custom module - this.dmlTable.goToRow(currentRow, { scrollIfVisible: false, focusRow: false }); - } - this.dmlTable.restoreRedraw(); this.blockClearHighlights = false; this.oldIndex = highlightIndex; } @@ -230,10 +226,6 @@ export class DMLView extends LitElement { } const newFindArgs = JSON.parse(JSON.stringify(e.detail)); - if (!isTableVisible) { - newFindArgs.text = ''; - } - const newSearch = newFindArgs.text !== this.findArgs.text || newFindArgs.options.matchCase !== this.findArgs.options?.matchCase; @@ -381,10 +373,6 @@ export class DMLView extends LitElement { const detailContainer = this.createDetailPanel(data.timestamp); row.getElement().replaceChildren(detailContainer); } - - requestAnimationFrame(() => { - formatter(row, this.findArgs); - }); }, }); @@ -469,7 +457,7 @@ export class DMLView extends LitElement { this.findArgs.text = ''; this.findArgs.count = 0; //@ts-expect-error This is a custom function added in by Find custom module - this.dmlTable.clearFindHighlights(Object.values(this.findMap)); + this.dmlTable.clearFindHighlights(); this.findMap = {}; this.totalMatches = 0; diff --git a/log-viewer/src/features/database/components/SOQLView.ts b/log-viewer/src/features/database/components/SOQLView.ts index af252a88..1fdf9866 100644 --- a/log-viewer/src/features/database/components/SOQLView.ts +++ b/log-viewer/src/features/database/components/SOQLView.ts @@ -27,7 +27,7 @@ import Number from '../../../tabulator/format/Number.js'; import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js'; import { GroupSort } from '../../../tabulator/groups/GroupSort.js'; import * as CommonModules from '../../../tabulator/module/CommonModules.js'; -import { Find, formatter } from '../../../tabulator/module/Find.js'; +import { Find } from '../../../tabulator/module/Find.js'; import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js'; import { RowNavigation } from '../../../tabulator/module/RowNavigation.js'; import dataGridStyles from '../../../tabulator/style/DataGrid.scss'; @@ -80,6 +80,7 @@ export class SOQLView extends LitElement { document.addEventListener('lv-find', this._findEvt); document.addEventListener('lv-find-close', this._findEvt); + document.addEventListener('lv-find-match', this._findEvt); } updated(changedProperties: PropertyValues): void { @@ -228,7 +229,7 @@ export class SOQLView extends LitElement { }); } - _highlightMatches(highlightIndex: number) { + async _highlightMatches(highlightIndex: number) { if (!this.soqlTable?.element?.clientHeight) { return; } @@ -236,17 +237,11 @@ export class SOQLView extends LitElement { this.findArgs.count = highlightIndex; const currentRow = this.findMap[highlightIndex]; this.blockClearHighlights = true; - this.soqlTable.blockRedraw(); - const rows = [currentRow, this.findMap[this.oldIndex]]; - rows.forEach((row) => { - row?.reformat(); + //@ts-expect-error This is a custom function added in by Find custom module + await this.soqlTable.setCurrentMatch(highlightIndex, currentRow, { + scrollIfVisible: false, + focusRow: false, }); - - if (currentRow) { - //@ts-expect-error This is a custom function added in by RowNavigation custom module - this.soqlTable.goToRow(currentRow, { scrollIfVisible: false, focusRow: false }); - } - this.soqlTable.restoreRedraw(); this.blockClearHighlights = false; this.oldIndex = highlightIndex; @@ -503,10 +498,6 @@ export class SOQLView extends LitElement { const detailContainer = this.createSOQLDetailPanel(data.timestamp, timestampToSOQl); row.getElement().replaceChildren(detailContainer); } - - requestAnimationFrame(() => { - formatter(row, this.findArgs); - }); }, }); @@ -591,7 +582,7 @@ export class SOQLView extends LitElement { this.findArgs.text = ''; this.findArgs.count = 0; //@ts-expect-error This is a custom function added in by Find custom module - this.soqlTable.clearFindHighlights(Object.values(this.findMap)); + this.soqlTable.clearFindHighlights(); this.findMap = {}; this.totalMatches = 0; diff --git a/log-viewer/src/styles/global.styles.ts b/log-viewer/src/styles/global.styles.ts index ec816c8b..5ad6d034 100644 --- a/log-viewer/src/styles/global.styles.ts +++ b/log-viewer/src/styles/global.styles.ts @@ -31,18 +31,13 @@ export const globalStyles = css` background-color: var(--vscode-scrollbarSlider-background); } - .findMatch { - animation-duration: 0; - animation-name: inherit !important; + ::highlight(find-match) { color: var(--vscode-editor-findMatchForeground); - background-color: var(--vscode-editor-findMatchHighlightBackground, 'yellow'); + background-color: var(--vscode-editor-findMatchHighlightBackground, yellow); } - .currentFindMatch { + ::highlight(current-find-match) { color: var(--vscode-editor-findMatchHighlightForeground); - background-color: var(--vscode-editor-findMatchBackground, '#8B8000'); - border: 2px solid var(--vscode-editor-findMatchBorder); - padding: 1px; - box-sizing: border-box; + background-color: var(--vscode-editor-findMatchBackground, #8b8000); } `; diff --git a/log-viewer/src/tabulator/module/Find.ts b/log-viewer/src/tabulator/module/Find.ts index 3bc46b2b..9c681c77 100644 --- a/log-viewer/src/tabulator/module/Find.ts +++ b/log-viewer/src/tabulator/module/Find.ts @@ -3,18 +3,68 @@ */ import { Module, type GroupComponent, type RowComponent, type Tabulator } from 'tabulator-tables'; +type FindArgs = { text: string; count: number; options: { matchCase: boolean } }; +type GoToRowOptions = { scrollIfVisible: boolean; focusRow: boolean }; + export class Find extends Module { static moduleName = 'FindModule'; + // Shared across all Find instances — one per highlight type + static _findHighlight: Highlight | null = null; + static _currentHighlight: Highlight | null = null; + + // Per-instance range tracking for cleanup without affecting other instances + _myFindRanges: Range[] = []; + _myCurrentRanges: Range[] = []; + _findArgs: FindArgs | null = null; + _currentMatchIndex = 0; + _matchIndexes: { [key: number]: RowComponent } = {}; + constructor(table: Tabulator) { super(table); // @ts-expect-error registerTableFunction() needs adding to tabulator types this.registerTableFunction('find', this._find.bind(this)); - // @ts-expect-error registerTableFunction() needs adding to tabulator types this.registerTableFunction('clearFindHighlights', this._clearFindHighlights.bind(this)); + // @ts-expect-error registerTableFunction() needs adding to tabulator types + this.registerTableFunction('setCurrentMatch', this._setCurrentMatch.bind(this)); } - initialize() {} + initialize() { + this.table.on('renderComplete', () => { + if (this._findArgs?.text) { + this._applyHighlights(); + } + }); + + // Virtual scroll doesn't fire renderComplete, so listen for scroll events + // to apply highlights to newly visible rows. Debounced to avoid blocking + // the main thread during fast scrolling, plus scrollend for instant final update. + const holder = this.table.element.querySelector('.tabulator-tableholder'); + if (holder) { + let rafId: number | null = null; + holder.addEventListener('scroll', () => { + if (!this._findArgs?.text) { + return; + } + if (rafId === null) { + rafId = requestAnimationFrame(() => { + rafId = null; + this._applyHighlights(); + }); + } + }); + + holder.addEventListener('scrollend', () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + if (this._findArgs?.text) { + this._applyHighlights(); + } + }); + } + } async _find(findArgs: FindArgs) { const result: { totalMatches: number; matchIndexes: { [key: number]: RowComponent } } = { @@ -22,7 +72,9 @@ export class Find extends Module { matchIndexes: {}, }; - this._clearMatches(); + this._clearInstanceRanges(); + this._currentMatchIndex = 0; + this._matchIndexes = {}; // We only do this when groups exist to get row order const flattenFromGrps = (row: GroupComponent): RowComponent[] => { @@ -31,7 +83,7 @@ export class Find extends Module { row .getSubGroups() .flatMap(flattenFromGrps) - .forEach((child) => { + .forEach((child: RowComponent) => { mergedArray.push(child); }); return mergedArray; @@ -39,29 +91,25 @@ export class Find extends Module { const tbl = this.table; const grps = tbl.getGroups().flatMap(flattenFromGrps); - const flattenedRows = grps.length ? grps : this._getRows(tbl.getRows('active')); + const flattenedRows: RowComponent[] = grps.length ? grps : this._getRows(tbl.getRows('active')); const findOptions = findArgs.options; let searchString = findOptions.matchCase ? findArgs.text : findArgs.text.toLowerCase(); searchString = searchString.replaceAll(/[[\]*+?{}.()^$|\\-]/g, '\\$&'); const regex = new RegExp(searchString, `g${findArgs.options.matchCase ? '' : 'i'}`); - tbl.blockRedraw(); + // Reset highlightIndexes on all rows (no reformat needed with CSS Highlight API) for (const row of flattenedRows) { const data = row.getData(); - if (data.highlightIndexes?.length > 0) { - data.highlightIndexes.length = 0; - row.reformat(); - } else if (!data.highlightIndexes) { + if (!data.highlightIndexes) { data.highlightIndexes = []; + } else { + data.highlightIndexes.length = 0; } } - tbl.restoreRedraw(); - await new Promise((resolve) => requestAnimationFrame(resolve)); let totalMatches = 0; if (searchString) { - const rowsToReformat = new Set(); const len = flattenedRows.length; for (let i = 0; i < len; i++) { const row = flattenedRows[i]; @@ -73,73 +121,187 @@ export class Find extends Module { data.highlightIndexes = []; row.getCells().forEach((cell) => { const elem = cell.getElement(); - const matchCount = this._countMatches(elem, findArgs, regex); + const matchCount = this._countMatches(elem, regex); if (matchCount) { - const kLen = matchCount; - for (let k = 0; k < kLen; k++) { + for (let k = 0; k < matchCount; k++) { totalMatches++; data.highlightIndexes.push(totalMatches); result.matchIndexes[totalMatches] = row; } - rowsToReformat.add(row); } }); } - tbl.blockRedraw(); - rowsToReformat.forEach((row) => { - row?.reformat(); - }); - tbl.restoreRedraw(); } result.totalMatches = totalMatches; + this._findArgs = findArgs; + this._matchIndexes = result.matchIndexes; + this._applyHighlights(); + return result; } - _clearFindHighlights(rows: RowComponent[]) { - this.table.blockRedraw(); + async _setCurrentMatch(index: number, row?: RowComponent, goToRowOpts?: GoToRowOptions) { + this._currentMatchIndex = index; + if (this._findArgs) { + this._findArgs.count = index; + } + this._applyHighlights(); + + if (row) { + try { + // @ts-expect-error goToRow is a custom function added by RowNavigation module + await this.table.goToRow(row, goToRowOpts); + } finally { + this._applyHighlights(); + } + } + } + + _applyHighlights() { + // Lazy-init static Highlights + if (!Find._findHighlight) { + Find._findHighlight = new Highlight(); + } + if (!Find._currentHighlight) { + Find._currentHighlight = new Highlight(); + } + + // Detach highlights during modification to prevent per-range style recalc + CSS.highlights.delete('find-match'); + CSS.highlights.delete('current-find-match'); + + // Clear this instance's old ranges from the shared Highlights + this._clearInstanceRanges(); + + if (!this._findArgs?.text) { + CSS.highlights.set('find-match', Find._findHighlight); + CSS.highlights.set('current-find-match', Find._currentHighlight); + return; + } + + const findOptions = this._findArgs.options; + let searchString = findOptions.matchCase + ? this._findArgs.text + : this._findArgs.text.toLowerCase(); + searchString = searchString.replaceAll(/[[\]*+?{}.()^$|\\-]/g, '\\$&'); + const regex = new RegExp(searchString, `g${findOptions.matchCase ? '' : 'i'}`); + + const rows = this._getRenderedRows(); for (const row of rows) { + // Skip GroupComponents — they don't have getData + if (typeof row.getData !== 'function') { + continue; + } + const data = row.getData(); - data.highlightIndexes = []; - row.reformat(); + + let matchIdx = 0; + row.getCells().forEach((cell) => { + const elem = cell.getElement(); + this._walkTextNodes(elem, (textNode) => { + const text = textNode.textContent; + if (!text) { + return; + } + + regex.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = regex.exec(text)) !== null) { + const highlightIndex = data.highlightIndexes?.[matchIdx]; + matchIdx++; + + const range = new Range(); + range.setStart(textNode, match.index); + range.setEnd(textNode, match.index + match[0].length); + + if (highlightIndex === this._currentMatchIndex) { + Find._currentHighlight!.add(range); + this._myCurrentRanges.push(range); + } else { + Find._findHighlight!.add(range); + this._myFindRanges.push(range); + } + } + }); + }); } - this.table.restoreRedraw(); + + // Re-attach highlights — browser applies all ranges in a single paint + CSS.highlights.set('find-match', Find._findHighlight!); + CSS.highlights.set('current-find-match', Find._currentHighlight!); } - _countMatches(elem: Node, findArgs: FindArgs, regex: RegExp) { - let count = 0; + _clearFindHighlights() { + this._clearInstanceRanges(); + this._findArgs = null; + this._currentMatchIndex = 0; + this._matchIndexes = {}; + } + + _clearInstanceRanges() { + for (const range of this._myFindRanges) { + Find._findHighlight?.delete(range); + } + for (const range of this._myCurrentRanges) { + Find._currentHighlight?.delete(range); + } + this._myFindRanges = []; + this._myCurrentRanges = []; + } + _walkTextNodes(node: Node, callback: (textNode: Text) => void) { const children = //@ts-expect-error renderRoot does not exist on node and we should probably not access it but there is no other option at the moment - (elem.childNodes?.length ? elem.childNodes : elem.renderRoot?.childNodes) ?? []; + (node.childNodes?.length ? node.childNodes : node.renderRoot?.childNodes) ?? []; const len = children.length; for (let i = 0; i < len; i++) { const cur = children[i]; if (!cur) { continue; } - if (cur.nodeType === 1) { - count += this._countMatches(cur, findArgs, regex); + this._walkTextNodes(cur, callback); } else if (cur.nodeType === 3) { - const originalText = cur.textContent; - if (!originalText) { - continue; - } - const match = originalText.match(regex); - count += match?.length ?? 0; + callback(cur as Text); } } + } + + _countMatches(elem: Node, regex: RegExp): number { + let count = 0; + this._walkTextNodes(elem, (textNode) => { + const text = textNode.textContent; + if (!text) { + return; + } + const match = text.match(regex); + count += match?.length ?? 0; + }); return count; } - _getRows(rows: RowComponent[]) { + _getRenderedRows(): RowComponent[] { + // Returns all rendered rows including buffer (not just viewport). + // This allows highlights to be applied to off-screen buffer rows, + // eliminating the "pop in" effect when they scroll into view. + const renderer = this.table.rowManager?.renderer; + if (renderer && renderer.vDomTop !== undefined) { + const displayRows = this.table.rowManager.getDisplayRows(); + return displayRows + .slice(renderer.vDomTop, renderer.vDomBottom + 1) + .map((row: { getComponent: () => RowComponent }) => row.getComponent()); + } + return this.table.getRows('visible'); + } + + _getRows(rows: RowComponent[]): RowComponent[] { const isDataTreeEnabled = this.table.modules.dataTree && this.table.options.dataTreeFilter; if (!isDataTreeEnabled) { return rows; } - const children = []; + const children: RowComponent[] = []; for (const row of rows) { children.push(row); for (const child of this._getFilteredChildren(row)) { @@ -164,10 +326,8 @@ export class Find extends Module { sorting.sort(internalChildren, true); } - const filteredChildren = []; for (const internalChild of internalChildren) { const childComp: RowComponent = internalChild.getComponent(); - filteredChildren.push(childComp); output.push(childComp); const subChildren = this._getFilteredChildren(childComp); @@ -178,122 +338,4 @@ export class Find extends Module { return output; } - - _clearMatches() { - const matches = this.table.element.querySelectorAll('.currentFindMatch, .findMatch'); - for (const elm of matches) { - const previous = elm.previousSibling; - const next = elm.nextSibling; - if (previous) { - const newText = (previous.textContent ?? '') + elm.textContent; - previous.textContent = newText; - - if (next) { - const newText = (previous.textContent ?? '') + next.textContent; - previous.textContent = newText; - } - elm.remove(); - } else { - if (next) { - const newText = (elm.textContent ?? '') + next.textContent; - elm.textContent = newText; - next.remove(); - } - elm.classList.remove('currentFindMatch', 'findMatch'); - } - } - } } - -export function formatter(row: RowComponent, findArgs: FindArgs) { - const { text } = findArgs; - if (!text) { - return; - } - const data = row.getData(); - if (!data || !data.highlightIndexes?.length) { - return; - } - - const highlights = { - indexes: data.highlightIndexes, - currentMatch: 0, - }; - row.getCells().forEach((cell) => { - const cellElem = cell.getElement(); - _highlightText(cellElem, findArgs, highlights); - }); - - //@ts-expect-error This is private to tabulator, but we have no other choice atm. - if (row._getSelf().type === 'row') { - row.normalizeHeight(); - } -} - -function _highlightText( - elem: Node, - findArgs: FindArgs, - highlights: { indexes: number[]; currentMatch: number }, -) { - const searchText = findArgs.options.matchCase ? findArgs.text : findArgs.text.toLowerCase(); - const matchHighlightIndex = findArgs.count; - - //@ts-expect-error renderRoot does not exist on node and we should probably not access it but there is no other option at the moment - const children = (elem.childNodes?.length ? elem.childNodes : elem.renderRoot?.childNodes) ?? []; - const len = children.length; - for (let i = 0; i < len; i++) { - const cur = children[i]; - if (!cur) { - continue; - } - - if (cur.nodeType === 1) { - _highlightText(cur, findArgs, highlights); - } else if (cur.nodeType === 3) { - const parentNode = cur.parentNode; - let originalText = cur.textContent; - if (!originalText) { - continue; - } - - let matchIndex = ( - findArgs.options.matchCase ? originalText : originalText?.toLowerCase() - )?.indexOf(searchText); - while (matchIndex > -1) { - const hightlightIndex = highlights.indexes[highlights.currentMatch++]; - - const endOfMatchIndex = matchIndex + searchText.length; - const matchingText = originalText.substring(matchIndex, endOfMatchIndex); - - const highlightSpan = document.createElement('span'); - highlightSpan.className = - hightlightIndex === matchHighlightIndex ? 'currentFindMatch' : 'findMatch'; - highlightSpan.textContent = matchingText; - if (parentNode.isEqualNode(highlightSpan)) { - break; - } - - if (matchIndex > 0) { - const beforeText = originalText.substring(0, matchIndex); - const beforeTextElem = document.createElement('text'); - beforeTextElem.textContent = beforeText; - parentNode?.insertBefore(beforeTextElem, cur); - } - parentNode?.insertBefore(highlightSpan, cur); - - const endText = originalText.substring(endOfMatchIndex, originalText.length); - if (!endText.length) { - parentNode?.removeChild(cur); - break; - } - cur.textContent = endText; - originalText = endText; - matchIndex = ( - findArgs.options.matchCase ? originalText : originalText?.toLowerCase() - )?.indexOf(searchText); - } - } - } -} - -type FindArgs = { text: string; count: number; options: { matchCase: boolean } }; From 18e53beda891a56bc5cfdccc0c31be835e76529e Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:47:36 +0100 Subject: [PATCH 04/14] refactor: improve goToRow with promise-based flow and scroll fallback Return a Promise from goToRow for async coordination. Use scrollIntoView as fallback after tabulator's scrollToRow to handle edge cases near the bottom of the grid. Check element connectivity in _isVisible and compare against tableholder bounds instead of window. --- .../src/tabulator/module/RowNavigation.ts | 109 ++++++++++-------- 1 file changed, 64 insertions(+), 45 deletions(-) diff --git a/log-viewer/src/tabulator/module/RowNavigation.ts b/log-viewer/src/tabulator/module/RowNavigation.ts index 9ce3b407..02b9023c 100644 --- a/log-viewer/src/tabulator/module/RowNavigation.ts +++ b/log-viewer/src/tabulator/module/RowNavigation.ts @@ -13,70 +13,89 @@ export class RowNavigation extends Module { this.registerTableFunction('goToRow', this.goToRow.bind(this)); } - goToRow(row: RowComponent, opts: GoToRowOptions = { scrollIfVisible: true, focusRow: true }) { - if (row) { - const { focusRow } = opts; + goToRow( + row: RowComponent, + opts: GoToRowOptions = { scrollIfVisible: true, focusRow: true }, + ): Promise { + if (!row) { + return Promise.resolve(); + } - const table = this.table; - this.tableHolder ??= table.element.querySelector('.tabulator-tableholder') as HTMLElement; + const { focusRow } = opts; - table.blockRedraw(); + const table = this.table; + this.tableHolder ??= table.element.querySelector('.tabulator-tableholder') as HTMLElement; - const grp = row.getGroup(); - if (grp && !grp.isVisible()) { - grp.show(); - } + table.blockRedraw(); - const rowsToExpand = []; - //@ts-expect-error This is private to tabulator, but we have no other choice atm. - let parent = row._getSelf().modules.dataTree ? row.getTreeParent() : false; - while (parent) { - if (!parent.isTreeExpanded()) { - rowsToExpand.push(parent); - } - parent = parent.getTreeParent(); + const grp = row.getGroup(); + if (grp && !grp.isVisible()) { + grp.show(); + } + + const rowsToExpand = []; + //@ts-expect-error This is private to tabulator, but we have no other choice atm. + let parent = row._getSelf().modules.dataTree ? row.getTreeParent() : false; + while (parent) { + if (!parent.isTreeExpanded()) { + rowsToExpand.push(parent); } + parent = parent.getTreeParent(); + } - rowsToExpand.forEach((row) => { - row.treeExpand(); - }); + for (const row of rowsToExpand) { + row.treeExpand(); + } - table.getSelectedRows().forEach((rowToDeselect) => { - rowToDeselect.deselect(); - }); - row.select(); - table.restoreRedraw(); + for (const row of table.getSelectedRows()) { + row.deselect(); + } - if (focusRow) { - this.tableHolder.focus(); - } - if (row) { - setTimeout(() => this._scrollToRow(row, opts)); - } + row.select(); + table.restoreRedraw(); + + if (focusRow) { + this.tableHolder.focus(); } + + return new Promise((resolve) => { + setTimeout(() => { + this._scrollToRow(row, opts).then(resolve); + }); + }); } - _scrollToRow(row: RowComponent, opts: GoToRowOptions) { + _scrollToRow(row: RowComponent, opts: GoToRowOptions): Promise { const { scrollIfVisible, focusRow } = opts; - this.table.scrollToRow(row, 'center', scrollIfVisible).then(() => { - setTimeout(() => { - const elem = row.getElement(); + return this.table + .scrollToRow(row, 'center', scrollIfVisible) + .catch(() => {}) + .then(() => { + return new Promise((resolve) => { + setTimeout(() => { + const elem = row.getElement(); + + if (scrollIfVisible || !this._isVisible(elem)) { + elem.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' }); + } - if (scrollIfVisible || !this._isVisible(elem)) { - // NOTE: work around because this.table.scrollToRow does not work correctly when the row is near the very bottom of the grid. - elem.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' }); - } + if (focusRow) { + elem.focus(); + } - if (focusRow) { - elem.focus(); - } + resolve(); + }); + }); }); - }); } _isVisible(el: Element) { + if (!this.tableHolder || !el.isConnected) { + return false; + } + const holderRect = this.tableHolder.getBoundingClientRect(); const rect = el.getBoundingClientRect(); - return rect.top >= 0 && rect.bottom <= window.innerHeight; + return rect.top >= holderRect.top && rect.bottom <= holderRect.bottom; } } From f8ebd42fa8fcb415f02f4ff26913557560935ebe Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:01:22 +0100 Subject: [PATCH 05/14] refactor: remove setTimeout deferrals from RowNavigation scroll flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two setTimeout wrappers in goToRow and _scrollToRow were deferring execution unnecessarily — goToRow now calls _scrollToRow directly, and _scrollToRow resolves synchronously after scrollIntoView rather than deferring to the next tick. --- .../src/tabulator/module/RowNavigation.ts | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/log-viewer/src/tabulator/module/RowNavigation.ts b/log-viewer/src/tabulator/module/RowNavigation.ts index 02b9023c..1bf787cb 100644 --- a/log-viewer/src/tabulator/module/RowNavigation.ts +++ b/log-viewer/src/tabulator/module/RowNavigation.ts @@ -58,11 +58,7 @@ export class RowNavigation extends Module { this.tableHolder.focus(); } - return new Promise((resolve) => { - setTimeout(() => { - this._scrollToRow(row, opts).then(resolve); - }); - }); + return this._scrollToRow(row, opts); } _scrollToRow(row: RowComponent, opts: GoToRowOptions): Promise { @@ -73,19 +69,17 @@ export class RowNavigation extends Module { .catch(() => {}) .then(() => { return new Promise((resolve) => { - setTimeout(() => { - const elem = row.getElement(); + const elem = row.getElement(); - if (scrollIfVisible || !this._isVisible(elem)) { - elem.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' }); - } + if (scrollIfVisible || !this._isVisible(elem)) { + elem.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' }); + } - if (focusRow) { - elem.focus(); - } + if (focusRow) { + elem.focus(); + } - resolve(); - }); + resolve(); }); }); } From 449f4eaf1c4a3a8d604555c46441ce5043dcb6b6 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:03:02 +0100 Subject: [PATCH 06/14] perf: extract and optimise call tree name column formatter Extracts the inline name formatter into CalltreeNameFormatter.ts using a factory/closure pattern. Key hot-path improvements: replaces createElement('span') with document.createTextNode() (no element allocation for plain text rows), eliminates private row._row.modules.dataTree access by computing treeLevel once in _toCallTree and storing it in row data, and reads dataTreeChildIndent via this.table on first render only. --- .../components/CalltreeNameFormatter.ts | 39 +++++++++++++++++++ .../call-tree/components/CalltreeView.ts | 38 ++++-------------- 2 files changed, 46 insertions(+), 31 deletions(-) create mode 100644 log-viewer/src/features/call-tree/components/CalltreeNameFormatter.ts diff --git a/log-viewer/src/features/call-tree/components/CalltreeNameFormatter.ts b/log-viewer/src/features/call-tree/components/CalltreeNameFormatter.ts new file mode 100644 index 00000000..35bc4a33 --- /dev/null +++ b/log-viewer/src/features/call-tree/components/CalltreeNameFormatter.ts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Certinia Inc. All rights reserved. + */ +import type { LogEvent, LogEventType } from 'apex-log-parser'; +import type { CellComponent, EmptyCallback } from 'tabulator-tables'; + +export function createCalltreeNameFormatter(excludedTypes: Set) { + let childIndent: number = 9; + + return function calltreeNameFormatter( + cell: CellComponent, + _formatterParams: object, + _onRendered: EmptyCallback, + ): string | HTMLElement { + const data = cell.getData() as { originalData: LogEvent; treeLevel: number }; + const { originalData: node, treeLevel } = data; + // @ts-expect-error this.table is added by tabulator when the formatter is called, but isn't in the types for some reason + childIndent ??= this.table.options.dataTreeChildIndent ?? 9; + const levelIndent = treeLevel * childIndent; + + const cellElem = cell.getElement(); + cellElem.style.paddingLeft = `${levelIndent + 4}px`; + cellElem.style.textIndent = `-${levelIndent}px`; + + if (node.hasValidSymbols) { + const link = document.createElement('a'); + link.setAttribute('href', '#!'); + link.textContent = node.text; + return link; + } + + let text = node.text; + if (node.type && node.type !== text && !excludedTypes.has(node.type)) { + text = node.type + ': ' + text; + } + + return document.createTextNode(text) as unknown as HTMLElement; + }; +} diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 01e25142..d84d3dd4 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -29,6 +29,7 @@ import { MiddleRowFocus } from '../../../tabulator/module/MiddleRowFocus.js'; import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js'; import { RowNavigation } from '../../../tabulator/module/RowNavigation.js'; import dataGridStyles from '../../../tabulator/style/DataGrid.scss'; +import { createCalltreeNameFormatter } from './CalltreeNameFormatter.js'; // styles import { globalStyles } from '../../../styles/global.styles.js'; @@ -570,7 +571,7 @@ export class CalltreeView extends LitElement { const excludedTypes = new Set(['SOQL_EXECUTE_BEGIN', 'DML_BEGIN']); const governorLimits = rootMethod.governorLimits; - let childIndent; + const nameFormatter = createCalltreeNameFormatter(excludedTypes); this.calltreeTable = new Tabulator(callTreeTableContainer, { data: this._toCallTree(rootMethod.children), layout: 'fitColumns', @@ -617,34 +618,7 @@ export class CalltreeView extends LitElement { return 'Total'; }, cssClass: 'datagrid-textarea datagrid-code-text', - formatter: (cell, _formatterParams, _onRendered) => { - const cellElem = cell.getElement(); - const row = cell.getRow(); - // @ts-expect-error: _row is private. This is temporary and I will patch the text wrap behaviour in the library. - const dataTree = row._row.modules.dataTree; - const treeLevel = dataTree?.index ?? 0; - childIndent ??= row.getTable().options.dataTreeChildIndent || 0; - const levelIndent = treeLevel * childIndent; - cellElem.style.paddingLeft = `${levelIndent + 4}px`; - cellElem.style.textIndent = `-${levelIndent}px`; - - const node = (cell.getData() as CalltreeRow).originalData; - let text = node.text; - if (node.hasValidSymbols) { - const link = document.createElement('a'); - link.setAttribute('href', '#!'); - link.textContent = text; - return link; - } - - if (node.type && !excludedTypes.has(node.type) && node.type !== text) { - text = node.type + ': ' + text; - } - - const textSpan = document.createElement('span'); - textSpan.textContent = text; - return textSpan; - }, + formatter: nameFormatter, variableHeight: true, cellClick: (e, cell) => { const { type } = window.getSelection() ?? {}; @@ -977,7 +951,7 @@ export class CalltreeView extends LitElement { } } - private _toCallTree(nodes: LogEvent[]): CalltreeRow[] | undefined { + private _toCallTree(nodes: LogEvent[], treeLevel = 0): CalltreeRow[] | undefined { const len = nodes.length; if (!len) { return undefined; @@ -989,12 +963,13 @@ export class CalltreeView extends LitElement { if (!node) { continue; } - const children = node.children.length ? this._toCallTree(node.children) : null; + const children = node.children.length ? this._toCallTree(node.children, treeLevel + 1) : null; results.push({ id: node.timestamp + '-' + i, originalData: node, _children: children, text: node.text, + treeLevel, namespace: node.namespace, duration: node.duration, dmlCount: node.dmlCount, @@ -1070,6 +1045,7 @@ interface CalltreeRow { originalData: LogEvent; _children: CalltreeRow[] | undefined | null; text: string; + treeLevel: number; duration: CountTotals; namespace: string; dmlCount: CountTotals; From df6ab9adb79e4aba46ce27fd7546714b62e65a72 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:31:32 +0100 Subject: [PATCH 07/14] fix: correct scroll-to-row centering for virtual DOM bottom-pad bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace scrollIntoView with \_centerRow that restores vDomBottomPad before setting scrollTop via offsetTop. Tabulator's \_addBottomRow zeroes vDomBottomPad when vDomBottom reaches the last row index — even for mid-table rows — shrinking scrollHeight and preventing center alignment. Also refactor \_scrollToRow to async/await. --- .../src/tabulator/module/RowNavigation.ts | 69 ++++++++++++++----- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/log-viewer/src/tabulator/module/RowNavigation.ts b/log-viewer/src/tabulator/module/RowNavigation.ts index 1bf787cb..d1e4524e 100644 --- a/log-viewer/src/tabulator/module/RowNavigation.ts +++ b/log-viewer/src/tabulator/module/RowNavigation.ts @@ -13,7 +13,7 @@ export class RowNavigation extends Module { this.registerTableFunction('goToRow', this.goToRow.bind(this)); } - goToRow( + async goToRow( row: RowComponent, opts: GoToRowOptions = { scrollIfVisible: true, focusRow: true }, ): Promise { @@ -58,30 +58,27 @@ export class RowNavigation extends Module { this.tableHolder.focus(); } - return this._scrollToRow(row, opts); + return new Promise((resolve) => { + // Need to wait for any pending redraws to finish before scrolling or it will not work + setTimeout(() => { + this._scrollToRow(row, opts).then(resolve); + }); + }); } - _scrollToRow(row: RowComponent, opts: GoToRowOptions): Promise { + async _scrollToRow(row: RowComponent, opts: GoToRowOptions): Promise { const { scrollIfVisible, focusRow } = opts; - return this.table - .scrollToRow(row, 'center', scrollIfVisible) - .catch(() => {}) - .then(() => { - return new Promise((resolve) => { - const elem = row.getElement(); - - if (scrollIfVisible || !this._isVisible(elem)) { - elem.scrollIntoView({ behavior: 'auto', block: 'center', inline: 'start' }); - } + await this.table.scrollToRow(row, 'center', scrollIfVisible); - if (focusRow) { - elem.focus(); - } + const elem = row.getElement(); + if (scrollIfVisible || !this._isVisible(elem)) { + this._centerRow(elem); + } - resolve(); - }); - }); + if (focusRow) { + elem.focus(); + } } _isVisible(el: Element) { @@ -92,4 +89,38 @@ export class RowNavigation extends Module { const rect = el.getBoundingClientRect(); return rect.top >= holderRect.top && rect.bottom <= holderRect.bottom; } + + // TODO: Remove once fixed upstream in tabulator-tables. + // + // Tabulator bug: _addBottomRow zeroes vDomBottomPad when vDomBottom reaches the last + // row index — even for mid-table rows in expanded trees. This shrinks scrollHeight, + // clamping scrollTop so scrollToRow places the row at the viewport bottom not center. + // Fix: restore the minimum vDomBottomPad needed for centering, then set scrollTop + // directly via offsetTop. + _centerRow(elem: HTMLElement) { + if (!this.tableHolder) return; + + // Only near-bottom rows have vDomBottomPad forced to 0 — skip the DOM write for + // all other rows where Tabulator already set it correctly. + const renderer = this.table.rowManager?.renderer as Record | undefined; + if (renderer && this.tableHolder && ((renderer.vDomBottomPad as number) ?? 0) === 0) { + const displayRows: unknown[] = this.table.rowManager?.getDisplayRows?.() ?? []; + const vDomBottom = (renderer.vDomBottom as number) ?? 0; + const vDomRowHeight = (renderer.vDomRowHeight as number) ?? 24; + const truePad = Math.max(0, (displayRows.length - vDomBottom - 1) * vDomRowHeight); + // Cap at clientHeight/2 — the maximum extra scroll range needed to center any row. + // Avoids large paddingBottom values for mid-table rows. If truePad < clientHeight/2 + // the row is genuinely near the bottom and the browser clamps naturally — no blank space. + const neededPad = Math.min(truePad, this.tableHolder.clientHeight / 2); + if (neededPad > 0) { + renderer.vDomBottomPad = neededPad; + (renderer.tableElement as HTMLElement).style.paddingBottom = `${neededPad}px`; + } + } + + // Reading elem.offsetTop forces a layout flush — paddingBottom is included in + // scrollHeight before scrollTop is assigned. + const holderHeight = this.tableHolder.clientHeight; + this.tableHolder.scrollTop = elem.offsetTop - holderHeight / 2 + elem.offsetHeight / 2; + } } From 17497a809f5d0361d2fa3fb3e9fa4052bf281ada Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:47:10 +0100 Subject: [PATCH 08/14] perf: optimise find search with headless formatters and CSS Highlight API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-cell DOM-based highlighting with CSS Highlight API ranges, eliminating row.reformat() calls during search. Extract searchable text via headless formatter execution with a reusable mock cell and per-row cache, bypassing getCells() and its O(rows × cols) DOM element creation. Skip innerHTML parsing for non-HTML formatter results. Highlight ranges now span across adjacent text nodes for cross-element match support. --- log-viewer/src/tabulator/module/Find.ts | 259 ++++++++++++++++++++---- 1 file changed, 217 insertions(+), 42 deletions(-) diff --git a/log-viewer/src/tabulator/module/Find.ts b/log-viewer/src/tabulator/module/Find.ts index 9c681c77..e45b95ff 100644 --- a/log-viewer/src/tabulator/module/Find.ts +++ b/log-viewer/src/tabulator/module/Find.ts @@ -1,7 +1,14 @@ /* * Copyright (c) 2024 Certinia Inc. All rights reserved. */ -import { Module, type GroupComponent, type RowComponent, type Tabulator } from 'tabulator-tables'; +import { + Module, + type CellComponent, + type ColumnComponent, + type GroupComponent, + type RowComponent, + type Tabulator, +} from 'tabulator-tables'; type FindArgs = { text: string; count: number; options: { matchCase: boolean } }; type GoToRowOptions = { scrollIfVisible: boolean; focusRow: boolean }; @@ -20,6 +27,18 @@ export class Find extends Module { _currentMatchIndex = 0; _matchIndexes: { [key: number]: RowComponent } = {}; + // Headless formatter execution: single detached element (never in the document) + // and a per-row-field text cache keyed by the stable row-data object reference. + _mockSearchElem: HTMLElement = document.createElement('div'); + _cellTextCache: WeakMap> = new WeakMap(); + + // Reusable mock cell — mutable fields updated before each formatter call. + _mcData: object = {}; + _mcValue: unknown = undefined; + _mcField = ''; + _mcColumn: ColumnComponent | null = null; + _mockCell: CellComponent = this._createMockCell(); + constructor(table: Tabulator) { super(table); // @ts-expect-error registerTableFunction() needs adding to tabulator types @@ -30,6 +49,12 @@ export class Find extends Module { } initialize() { + // Reset the text cache whenever a new dataset is loaded so stale entries + // from the previous log never pollute a fresh search. + this.table.on('tableBuilt', () => { + this._cellTextCache = new WeakMap(); + }); + this.table.on('renderComplete', () => { if (this._findArgs?.text) { this._applyHighlights(); @@ -110,6 +135,28 @@ export class Find extends Module { let totalMatches = 0; if (searchString) { + // Avoid row.getCells() — for uninitialized off-screen rows it calls generateCells() + // which creates a DOM element per cell (document.createElement). For 10k rows that + // is O(rows × cols) element creation before a single search character is matched. + // Instead iterate columnsByIndex directly, which is the same array generateCells() + // would use, avoiding all Cell object and DOM element creation. + // columnManager.getRealColumns() is internal — returns columnsByIndex, same order as getCells() + const internalCols: Array<{ + field: string; + getComponent: () => ColumnComponent; + getFieldValue: (data: object) => unknown; + modules?: { + format?: { + formatter?: ( + cell: CellComponent, + params: object, + onRendered: () => void, + ) => string | HTMLElement; + params?: object | ((cell: CellComponent) => object); + }; + }; + }> = this.table.columnManager?.getRealColumns?.() ?? []; + const len = flattenedRows.length; for (let i = 0; i < len; i++) { const row = flattenedRows[i]; @@ -119,9 +166,31 @@ export class Find extends Module { const data = row.getData(); data.highlightIndexes = []; - row.getCells().forEach((cell) => { - const elem = cell.getElement(); - const matchCount = this._countMatches(elem, regex); + + let rowCache = this._cellTextCache.get(data as object); + if (!rowCache) { + rowCache = new Map(); + this._cellTextCache.set(data as object, rowCache); + } + + for (const col of internalCols) { + const field = col.field; + if (!field) continue; + + let text = rowCache.get(field); + if (text === undefined) { + text = this._runFormatterForColumn( + data as object, + field, + col.getFieldValue(data as object), + col.getComponent(), + col.modules?.format, + ); + rowCache.set(field, text); + } + + regex.lastIndex = 0; + const matchCount = text.match(regex)?.length ?? 0; if (matchCount) { for (let k = 0; k < matchCount; k++) { totalMatches++; @@ -129,7 +198,7 @@ export class Find extends Module { result.matchIndexes[totalMatches] = row; } } - }); + } } } @@ -199,31 +268,32 @@ export class Find extends Module { let matchIdx = 0; row.getCells().forEach((cell) => { const elem = cell.getElement(); - this._walkTextNodes(elem, (textNode) => { - const text = textNode.textContent; - if (!text) { - return; - } - - regex.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = regex.exec(text)) !== null) { - const highlightIndex = data.highlightIndexes?.[matchIdx]; - matchIdx++; - - const range = new Range(); - range.setStart(textNode, match.index); - range.setEnd(textNode, match.index + match[0].length); - - if (highlightIndex === this._currentMatchIndex) { - Find._currentHighlight!.add(range); - this._myCurrentRanges.push(range); - } else { - Find._findHighlight!.add(range); - this._myFindRanges.push(range); - } + // Build a flat text-node map so we can create Ranges that span across + // adjacent elements (e.g. two s whose text forms a single match). + const { text: fullText, nodes: textNodeMap } = this._buildTextNodeMap(elem); + if (!fullText) return; + + regex.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = regex.exec(fullText)) !== null) { + const highlightIndex = data.highlightIndexes?.[matchIdx]; + matchIdx++; + + const range = this._createMatchRange( + textNodeMap, + match.index, + match.index + match[0].length, + ); + if (!range) continue; + + if (highlightIndex === this._currentMatchIndex) { + Find._currentHighlight!.add(range); + this._myCurrentRanges.push(range); + } else { + Find._findHighlight!.add(range); + this._myFindRanges.push(range); } - }); + } }); } @@ -268,19 +338,6 @@ export class Find extends Module { } } - _countMatches(elem: Node, regex: RegExp): number { - let count = 0; - this._walkTextNodes(elem, (textNode) => { - const text = textNode.textContent; - if (!text) { - return; - } - const match = text.match(regex); - count += match?.length ?? 0; - }); - return count; - } - _getRenderedRows(): RowComponent[] { // Returns all rendered rows including buffer (not just viewport). // This allows highlights to be applied to off-screen buffer rows, @@ -338,4 +395,122 @@ export class Find extends Module { return output; } + + // Collects all text nodes under root with their cumulative byte offsets into a + // flat array, plus the concatenated full text. Used by _applyHighlights so + // that a single regex match can span multiple sibling elements. + _buildTextNodeMap(root: Node): { text: string; nodes: Array<{ node: Text; start: number }> } { + const nodes: Array<{ node: Text; start: number }> = []; + let offset = 0; + this._walkTextNodes(root, (textNode) => { + nodes.push({ node: textNode, start: offset }); + offset += textNode.textContent?.length ?? 0; + }); + return { text: nodes.map((n) => n.node.textContent ?? '').join(''), nodes }; + } + + // Creates a Range covering [matchStart, matchEnd) in the text-node map + // produced by _buildTextNodeMap. Supports ranges that cross node boundaries. + _createMatchRange( + nodes: Array<{ node: Text; start: number }>, + matchStart: number, + matchEnd: number, + ): Range | null { + const range = new Range(); + let startSet = false; + + for (const { node, start } of nodes) { + const nodeEnd = start + (node.textContent?.length ?? 0); + + if (!startSet && matchStart < nodeEnd) { + range.setStart(node, matchStart - start); + startSet = true; + } + + if (startSet && matchEnd <= nodeEnd) { + range.setEnd(node, matchEnd - start); + return range; + } + } + + return null; + } + + // Runs the column formatter headlessly for a given row data + column, without + // requiring a CellComponent. This avoids row.getCells(), which triggers + // generateCells() and DOM element creation for every uninitialized off-screen row. + // Results are cached by (rowData, field) and reused on repeat searches. + _createMockCell(): CellComponent { + const mockElem = this._mockSearchElem; + return { + getElement: () => mockElem, + getData: () => this._mcData, + getValue: () => this._mcValue, + getInitialValue: () => this._mcValue, + getField: () => this._mcField, + getRow: () => ({ getData: () => this._mcData }) as unknown as RowComponent, + getColumn: () => this._mcColumn!, + checkHeight: () => {}, + edit: () => {}, + cancelEdit: () => {}, + isEdited: () => false, + clearEdited: () => {}, + isValid: () => true, + clearValidation: () => {}, + validate: () => true, + popup: () => {}, + } as unknown as CellComponent; + } + + _runFormatterForColumn( + data: object, + field: string, + value: unknown, + columnComponent: ColumnComponent, + fmt: + | { + formatter?: ( + cell: CellComponent, + params: object, + onRendered: () => void, + ) => string | HTMLElement; + params?: object | ((cell: CellComponent) => object); + } + | undefined, + ): string { + if (!fmt?.formatter) { + return String(value ?? ''); + } + + this._mcData = data; + this._mcValue = value; + this._mcField = field; + this._mcColumn = columnComponent; + + const mockCell = this._mockCell; + const resolvedParams = + typeof fmt.params === 'function' ? fmt.params(mockCell) : (fmt.params ?? {}); + + let result: string | HTMLElement | undefined; + try { + const ctx: object = this.table.modules?.format ?? { table: this.table }; + result = fmt.formatter.call(ctx, mockCell, resolvedParams, () => {}); + } catch { + return String(value ?? ''); + } + + if (typeof result === 'string') { + if (result.includes('<') && result.includes('>')) { + const mockElem = this._mockSearchElem; + mockElem.innerHTML = result; + const text = mockElem.textContent ?? ''; + mockElem.textContent = ''; + return text; + } + + return result; + } + + return result?.textContent ?? ''; + } } From 1448b816dc4a15092d4fd639ef4b69f2fd1b96e6 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:49:11 +0100 Subject: [PATCH 09/14] perf: convert progress formatter from HTML string to HTMLElement Eliminates innerHTML parsing in Find search by returning DOM elements directly. _runFormatterForColumn now reads .textContent instead of parsing HTML via innerHTML for every progress column cell. --- log-viewer/src/tabulator/format/Progress.ts | 2 +- .../src/tabulator/format/ProgressComponent.ts | 40 ++++++++++++++----- log-viewer/src/tabulator/format/ProgressMS.ts | 2 +- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/log-viewer/src/tabulator/format/Progress.ts b/log-viewer/src/tabulator/format/Progress.ts index eae1d89b..2efd55e5 100644 --- a/log-viewer/src/tabulator/format/Progress.ts +++ b/log-viewer/src/tabulator/format/Progress.ts @@ -9,7 +9,7 @@ export function progressFormatter( cell: CellComponent, formatterParams: ProgressParams, _onRendered: EmptyCallback, -) { +): string | HTMLElement { const value = cell.getValue() ?? 0; const totalVal = formatterParams.totalValue ?? 0; diff --git a/log-viewer/src/tabulator/format/ProgressComponent.ts b/log-viewer/src/tabulator/format/ProgressComponent.ts index ec17764c..0cedf6e3 100644 --- a/log-viewer/src/tabulator/format/ProgressComponent.ts +++ b/log-viewer/src/tabulator/format/ProgressComponent.ts @@ -7,7 +7,11 @@ type ProgressOptions = { precision?: number; }; -export function progressComponent(value: number, totalValue: number, options: ProgressOptions) { +export function progressComponent( + value: number, + totalValue: number, + options: ProgressOptions, +): string | HTMLElement { const { showPercentageText = true, precision = 2 } = options; const roundedValue = (value || 0).toFixed(precision); @@ -17,18 +21,32 @@ export function progressComponent(value: number, totalValue: number, options: Pr const percentComplete = totalValue !== 0 ? (Math.round((value / totalValue) * 100) / 100) * 100 : 0; - const percentageText = showPercent ? `(${percentComplete.toFixed(2)}%)` : ''; + const wrapper = document.createElement('div'); + wrapper.className = 'progress-wrapper'; - const progressBarElem = `${percentComplete ? `
` : ''}`; - const progressBarTextElem = `
- ${roundedValue} - ${showPercent ? `${percentageText}` : ''} -
`; + if (percentComplete) { + const bar = document.createElement('div'); + bar.className = 'progress-bar'; + bar.style.width = `${percentComplete}%`; + wrapper.appendChild(bar); + } - return `
- ${progressBarElem} - ${progressBarTextElem} -
`; + const textEl = document.createElement('div'); + textEl.className = 'progress-bar__text'; + + const valueSpan = document.createElement('span'); + valueSpan.textContent = roundedValue; + textEl.appendChild(valueSpan); + + if (showPercent) { + const pctSpan = document.createElement('span'); + pctSpan.className = 'progress-bar__text__percent'; + pctSpan.textContent = `(${percentComplete.toFixed(2)}%)`; + textEl.appendChild(pctSpan); + } + + wrapper.appendChild(textEl); + return wrapper; } return roundedValue; diff --git a/log-viewer/src/tabulator/format/ProgressMS.ts b/log-viewer/src/tabulator/format/ProgressMS.ts index 63d9b302..bd70a5ce 100644 --- a/log-viewer/src/tabulator/format/ProgressMS.ts +++ b/log-viewer/src/tabulator/format/ProgressMS.ts @@ -9,7 +9,7 @@ export function progressFormatterMS( cell: CellComponent, formatterParams: ProgressParams, _onRendered: EmptyCallback, -) { +): string | HTMLElement { const value = (cell.getValue() || 0) / 1_000_000; const totalVal = formatterParams.totalValue ?? 0; const totalValAsMs = totalVal > 0 ? totalVal / 1_000_000 : 0; From 9a1d9a110a6c732a780a52587a527974c3bb4f01 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:07:35 +0100 Subject: [PATCH 10/14] docs: update changelog --- CHANGELOG.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8082a4e7..f4415a53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,12 +79,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- 🎯 **Call Tree Go To**: Go-to links in call tree now navigate to method definition instead of where method was called from ([#632] [#200]) +- 🎯 **Call Tree Go To**: Go-to links in call tree now navigate to method definition instead of where method was called from. ([#632] [#200]) - 🔍 **Search Navigation**: `Shift+Enter` navigates to the previous search result; hold `Enter` to continuously navigate. -- ⚡ **Log Parsing**: Improved performance ([#552]) -- ✨ **Duration Formatting**: Human-readable duration formatting in tooltips (30000 ms -> 30s and 0.01 ms -> 10 µs) ([#671]) -- 🎯 **Number Precision**: Total and Self Time column precision changed to 2 decimal places for improved readability ([#671]) -- 🎨 **Navigation Bar**: Redesigned to better match VS Code’s look and feel ([#694]) +- ⚡ **Search Performance**: Up to 10x faster search on large logs. ([#627]) +- ⚡ **Log Parsing**: Improved performance. ([#552]) +- ✨ **Duration Formatting**: Human-readable duration formatting in tooltips (30000 ms -> 30s and 0.01 ms -> 10 µs). ([#671]) +- 🎯 **Number Precision**: Total and Self Time column precision changed to 2 decimal places for improved readability. ([#671]) +- 🎨 **Navigation Bar**: Redesigned to better match VS Code’s look and feel. ([#694]) ## [1.18.1] 2025-07-09 @@ -476,6 +477,7 @@ Skipped due to adopting odd numbering for pre releases and even number for relea +[#627]: https://github.com/certinia/debug-log-analyzer/issues/627 [#685]: https://github.com/certinia/debug-log-analyzer/issues/685 [#98]: https://github.com/certinia/debug-log-analyzer/issues/98 [#204]: https://github.com/certinia/debug-log-analyzer/issues/204 From 8c325c0023cb01a30323e15e826ad1efd00520ed Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:55:25 +0100 Subject: [PATCH 11/14] fix: cache find regex and clean up highlights on table destroy Cache the compiled RegExp on the Find instance so _applyHighlights() (called on every scroll RAF) avoids repeated string escaping and RegExp construction. Clear stale highlight ranges on tableDestroyed to prevent memory leaks when table instances are torn down. --- log-viewer/src/tabulator/module/Find.ts | 36 +++++++++++++++++-------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/log-viewer/src/tabulator/module/Find.ts b/log-viewer/src/tabulator/module/Find.ts index e45b95ff..278d941a 100644 --- a/log-viewer/src/tabulator/module/Find.ts +++ b/log-viewer/src/tabulator/module/Find.ts @@ -24,6 +24,7 @@ export class Find extends Module { _myFindRanges: Range[] = []; _myCurrentRanges: Range[] = []; _findArgs: FindArgs | null = null; + _cachedRegex: RegExp | null = null; _currentMatchIndex = 0; _matchIndexes: { [key: number]: RowComponent } = {}; @@ -55,6 +56,10 @@ export class Find extends Module { this._cellTextCache = new WeakMap(); }); + this.table.on('tableDestroyed', () => { + this._clearFindHighlights(); + }); + this.table.on('renderComplete', () => { if (this._findArgs?.text) { this._applyHighlights(); @@ -118,10 +123,7 @@ export class Find extends Module { const grps = tbl.getGroups().flatMap(flattenFromGrps); const flattenedRows: RowComponent[] = grps.length ? grps : this._getRows(tbl.getRows('active')); - const findOptions = findArgs.options; - let searchString = findOptions.matchCase ? findArgs.text : findArgs.text.toLowerCase(); - searchString = searchString.replaceAll(/[[\]*+?{}.()^$|\\-]/g, '\\$&'); - const regex = new RegExp(searchString, `g${findArgs.options.matchCase ? '' : 'i'}`); + const regex = this._buildRegex(findArgs); // Reset highlightIndexes on all rows (no reformat needed with CSS Highlight API) for (const row of flattenedRows) { @@ -134,7 +136,7 @@ export class Find extends Module { } let totalMatches = 0; - if (searchString) { + if (regex) { // Avoid row.getCells() — for uninitialized off-screen rows it calls generateCells() // which creates a DOM element per cell (document.createElement). For 10k rows that // is O(rows × cols) element creation before a single search character is matched. @@ -249,12 +251,12 @@ export class Find extends Module { return; } - const findOptions = this._findArgs.options; - let searchString = findOptions.matchCase - ? this._findArgs.text - : this._findArgs.text.toLowerCase(); - searchString = searchString.replaceAll(/[[\]*+?{}.()^$|\\-]/g, '\\$&'); - const regex = new RegExp(searchString, `g${findOptions.matchCase ? '' : 'i'}`); + const regex = this._cachedRegex; + if (!regex) { + CSS.highlights.set('find-match', Find._findHighlight); + CSS.highlights.set('current-find-match', Find._currentHighlight); + return; + } const rows = this._getRenderedRows(); for (const row of rows) { @@ -305,10 +307,22 @@ export class Find extends Module { _clearFindHighlights() { this._clearInstanceRanges(); this._findArgs = null; + this._cachedRegex = null; this._currentMatchIndex = 0; this._matchIndexes = {}; } + _buildRegex(findArgs: FindArgs): RegExp | null { + if (!findArgs.text) { + this._cachedRegex = null; + return null; + } + let searchString = findArgs.options.matchCase ? findArgs.text : findArgs.text.toLowerCase(); + searchString = searchString.replaceAll(/[[\]*+?{}.()^$|\\-]/g, '\\$&'); + this._cachedRegex = new RegExp(searchString, `g${findArgs.options.matchCase ? '' : 'i'}`); + return this._cachedRegex; + } + _clearInstanceRanges() { for (const range of this._myFindRanges) { Find._findHighlight?.delete(range); From 344ca2d3a9683d102c5c6503358b0db9b93c1094 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:57:22 +0100 Subject: [PATCH 12/14] fix: remove document event listener leaks in view components Add disconnectedCallback to CalltreeView, AnalysisView, DMLView, and SOQLView to remove find event listeners registered on document. Extract CalltreeView's inline calltree-go-to-row handler to a named field so it can be properly removed. Restore the isTableVisible guard in DMLView to prevent searching against hidden tables when switching views. --- .../analysis/components/AnalysisView.ts | 7 +++++++ .../call-tree/components/CalltreeView.ts | 17 +++++++++++++---- .../src/features/database/components/DMLView.ts | 11 +++++++++++ .../features/database/components/SOQLView.ts | 7 +++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index d46f920d..d820b5c9 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -126,6 +126,13 @@ export class AnalysisView extends LitElement { document.addEventListener('lv-find-close', this._findEvt); } + disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener('lv-find', this._findEvt); + document.removeEventListener('lv-find-match', this._findEvt); + document.removeEventListener('lv-find-close', this._findEvt); + } + updated(changedProperties: PropertyValues): void { if ( this.timelineRoot && diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index d84d3dd4..24022979 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -80,18 +80,27 @@ export class CalltreeView extends LitElement { return (this.tableContainer = this.renderRoot?.querySelector('#call-tree-table') ?? null); } + private _goToRowEvt = ((e: CustomEvent) => { + this._goToRow(e.detail.timestamp); + }) as EventListener; + constructor() { super(); - document.addEventListener('calltree-go-to-row', ((e: CustomEvent) => { - this._goToRow(e.detail.timestamp); - }) as EventListener); - + document.addEventListener('calltree-go-to-row', this._goToRowEvt); document.addEventListener('lv-find', this._findEvt); document.addEventListener('lv-find-match', this._findEvt); document.addEventListener('lv-find-close', this._findEvt); } + disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener('calltree-go-to-row', this._goToRowEvt); + document.removeEventListener('lv-find', this._findEvt); + document.removeEventListener('lv-find-match', this._findEvt); + document.removeEventListener('lv-find-close', this._findEvt); + } + updated(changedProperties: PropertyValues): void { if ( this.timelineRoot && diff --git a/log-viewer/src/features/database/components/DMLView.ts b/log-viewer/src/features/database/components/DMLView.ts index c921b46f..988382e2 100644 --- a/log-viewer/src/features/database/components/DMLView.ts +++ b/log-viewer/src/features/database/components/DMLView.ts @@ -72,6 +72,13 @@ export class DMLView extends LitElement { document.addEventListener('lv-find-match', this._findEvt); } + disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener('lv-find', this._findEvt); + document.removeEventListener('lv-find-close', this._findEvt); + document.removeEventListener('lv-find-match', this._findEvt); + } + updated(changedProperties: PropertyValues): void { if ( this.timelineRoot && @@ -226,6 +233,10 @@ export class DMLView extends LitElement { } const newFindArgs = JSON.parse(JSON.stringify(e.detail)); + if (!isTableVisible) { + newFindArgs.text = ''; + } + const newSearch = newFindArgs.text !== this.findArgs.text || newFindArgs.options.matchCase !== this.findArgs.options?.matchCase; diff --git a/log-viewer/src/features/database/components/SOQLView.ts b/log-viewer/src/features/database/components/SOQLView.ts index 1fdf9866..8515c8c7 100644 --- a/log-viewer/src/features/database/components/SOQLView.ts +++ b/log-viewer/src/features/database/components/SOQLView.ts @@ -83,6 +83,13 @@ export class SOQLView extends LitElement { document.addEventListener('lv-find-match', this._findEvt); } + disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener('lv-find', this._findEvt); + document.removeEventListener('lv-find-close', this._findEvt); + document.removeEventListener('lv-find-match', this._findEvt); + } + updated(changedProperties: PropertyValues): void { if ( this.timelineRoot && From 753e651d981baf321418c808a59050eafc1b6066 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:58:22 +0100 Subject: [PATCH 13/14] fix: allow dataTreeChildIndent config to override default in name formatter Remove premature initialisation of childIndent so the ??= fallback correctly reads dataTreeChildIndent from table options on first use. --- .../src/features/call-tree/components/CalltreeNameFormatter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/log-viewer/src/features/call-tree/components/CalltreeNameFormatter.ts b/log-viewer/src/features/call-tree/components/CalltreeNameFormatter.ts index 35bc4a33..3663c661 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeNameFormatter.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeNameFormatter.ts @@ -5,7 +5,7 @@ import type { LogEvent, LogEventType } from 'apex-log-parser'; import type { CellComponent, EmptyCallback } from 'tabulator-tables'; export function createCalltreeNameFormatter(excludedTypes: Set) { - let childIndent: number = 9; + let childIndent: number; return function calltreeNameFormatter( cell: CellComponent, From b5dc58fe3d532427b0b08e70b8a77232660fb41b Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:49:08 +0100 Subject: [PATCH 14/14] fix: resolve database view find count and navigation regressions Prevent non-visible views from dispatching lv-find-results which overwrote the active view's match count in FindWidget. Fall back to the raw field value in headless formatter when custom elements like yield empty textContent in a detached element. Remove lv-find-match from DMLView/SOQLView since DatabaseView orchestrates match navigation via highlightIndex property binding. Fix SOQLView dispatching db-find-results with type 'dml' instead of 'soql'. Add disconnectedCallback to DatabaseView for listener cleanup. --- .../src/features/analysis/components/AnalysisView.ts | 4 ++-- .../src/features/call-tree/components/CalltreeView.ts | 4 ++-- log-viewer/src/features/database/components/DMLView.ts | 6 ------ .../src/features/database/components/DatabaseView.ts | 7 +++++++ .../src/features/database/components/SOQLView.ts | 4 +--- log-viewer/src/tabulator/module/Find.ts | 10 ++++++++-- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index d820b5c9..eaef06c1 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -247,14 +247,14 @@ export class AnalysisView extends LitElement { this.totalMatches = result.totalMatches; this.findMap = result.matchIndexes; - if (!clearHighlights) { + if (!clearHighlights && isTableVisible) { document.dispatchEvent( new CustomEvent('lv-find-results', { detail: { totalMatches: result.totalMatches } }), ); } } - if (this.totalMatches <= 0) { + if (this.totalMatches <= 0 || !isTableVisible) { return; } this.blockClearHighlights = true; diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 24022979..2a2a0629 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -395,7 +395,7 @@ export class CalltreeView extends LitElement { this.totalMatches = result.totalMatches; this.findMap = result.matchIndexes; - if (!clearHighlights) { + if (!clearHighlights && isTableVisible) { document.dispatchEvent( new CustomEvent('lv-find-results', { detail: { totalMatches: result.totalMatches } }), ); @@ -403,7 +403,7 @@ export class CalltreeView extends LitElement { } // Highlight the current row and reset the previous or next depending on whether we are stepping forward or back. - if (this.totalMatches <= 0) { + if (this.totalMatches <= 0 || !isTableVisible) { return; } this.blockClearHighlights = true; diff --git a/log-viewer/src/features/database/components/DMLView.ts b/log-viewer/src/features/database/components/DMLView.ts index 988382e2..da0bf100 100644 --- a/log-viewer/src/features/database/components/DMLView.ts +++ b/log-viewer/src/features/database/components/DMLView.ts @@ -69,14 +69,12 @@ export class DMLView extends LitElement { document.addEventListener('lv-find', this._findEvt); document.addEventListener('lv-find-close', this._findEvt); - document.addEventListener('lv-find-match', this._findEvt); } disconnectedCallback(): void { super.disconnectedCallback(); document.removeEventListener('lv-find', this._findEvt); document.removeEventListener('lv-find-close', this._findEvt); - document.removeEventListener('lv-find-match', this._findEvt); } updated(changedProperties: PropertyValues): void { @@ -233,10 +231,6 @@ export class DMLView extends LitElement { } const newFindArgs = JSON.parse(JSON.stringify(e.detail)); - if (!isTableVisible) { - newFindArgs.text = ''; - } - const newSearch = newFindArgs.text !== this.findArgs.text || newFindArgs.options.matchCase !== this.findArgs.options?.matchCase; diff --git a/log-viewer/src/features/database/components/DatabaseView.ts b/log-viewer/src/features/database/components/DatabaseView.ts index c6d6e48f..bc0f9961 100644 --- a/log-viewer/src/features/database/components/DatabaseView.ts +++ b/log-viewer/src/features/database/components/DatabaseView.ts @@ -45,6 +45,13 @@ export class DatabaseView extends LitElement { document.addEventListener('lv-find', this._findHandler as EventListener); } + disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener('db-find-results', this._findResults as EventListener); + document.removeEventListener('lv-find-match', this._findHandler as EventListener); + document.removeEventListener('lv-find', this._findHandler as EventListener); + } + static styles = [ globalStyles, css` diff --git a/log-viewer/src/features/database/components/SOQLView.ts b/log-viewer/src/features/database/components/SOQLView.ts index 8515c8c7..6458ed7a 100644 --- a/log-viewer/src/features/database/components/SOQLView.ts +++ b/log-viewer/src/features/database/components/SOQLView.ts @@ -80,14 +80,12 @@ export class SOQLView extends LitElement { document.addEventListener('lv-find', this._findEvt); document.addEventListener('lv-find-close', this._findEvt); - document.addEventListener('lv-find-match', this._findEvt); } disconnectedCallback(): void { super.disconnectedCallback(); document.removeEventListener('lv-find', this._findEvt); document.removeEventListener('lv-find-close', this._findEvt); - document.removeEventListener('lv-find-match', this._findEvt); } updated(changedProperties: PropertyValues): void { @@ -595,7 +593,7 @@ export class SOQLView extends LitElement { document.dispatchEvent( new CustomEvent('db-find-results', { - detail: { totalMatches: this.totalMatches, type: 'dml' }, + detail: { totalMatches: this.totalMatches, type: 'soql' }, }), ); } diff --git a/log-viewer/src/tabulator/module/Find.ts b/log-viewer/src/tabulator/module/Find.ts index 278d941a..0025caf1 100644 --- a/log-viewer/src/tabulator/module/Find.ts +++ b/log-viewer/src/tabulator/module/Find.ts @@ -513,18 +513,24 @@ export class Find extends Module { return String(value ?? ''); } + // Fall back to the raw field value when the formatter result has no + // extractable text. This happens for custom-element HTML strings like + // whose content renders via shadow DOM — in a detached + // element they never connect, so textContent is empty. + const fallback = String(value ?? ''); + if (typeof result === 'string') { if (result.includes('<') && result.includes('>')) { const mockElem = this._mockSearchElem; mockElem.innerHTML = result; const text = mockElem.textContent ?? ''; mockElem.textContent = ''; - return text; + return text || fallback; } return result; } - return result?.textContent ?? ''; + return result?.textContent || fallback; } }