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 diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index ea3cd4d9..eaef06c1 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'; @@ -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 && @@ -240,30 +247,23 @@ 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; - 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 +321,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 +423,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 +453,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/CalltreeNameFormatter.ts b/log-viewer/src/features/call-tree/components/CalltreeNameFormatter.ts new file mode 100644 index 00000000..3663c661 --- /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; + + 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 a7f31098..2a2a0629 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -24,11 +24,12 @@ 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'; import dataGridStyles from '../../../tabulator/style/DataGrid.scss'; +import { createCalltreeNameFormatter } from './CalltreeNameFormatter.js'; // styles import { globalStyles } from '../../../styles/global.styles.js'; @@ -79,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 && @@ -385,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 } }), ); @@ -393,26 +403,16 @@ 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; - 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; } @@ -580,7 +580,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', @@ -610,9 +610,6 @@ export class CalltreeView extends LitElement { return "
"; } }, - rowFormatter: (row: RowComponent) => { - formatter(row, this.findArgs); - }, columnCalcs: 'both', columnDefaults: { title: 'default', @@ -630,34 +627,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() ?? {}; @@ -867,7 +837,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 +876,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; } @@ -983,7 +960,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; @@ -995,12 +972,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, @@ -1076,6 +1054,7 @@ interface CalltreeRow { originalData: LogEvent; _children: CalltreeRow[] | undefined | null; text: string; + treeLevel: number; duration: CountTotals; namespace: string; dmlCount: CountTotals; diff --git a/log-viewer/src/features/database/components/DMLView.ts b/log-viewer/src/features/database/components/DMLView.ts index 19d551a2..da0bf100 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'; @@ -71,6 +71,12 @@ export class DMLView extends LitElement { document.addEventListener('lv-find-close', this._findEvt); } + disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener('lv-find', this._findEvt); + document.removeEventListener('lv-find-close', this._findEvt); + } + updated(changedProperties: PropertyValues): void { if ( this.timelineRoot && @@ -201,24 +207,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 +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; @@ -381,10 +378,6 @@ export class DMLView extends LitElement { const detailContainer = this.createDetailPanel(data.timestamp); row.getElement().replaceChildren(detailContainer); } - - requestAnimationFrame(() => { - formatter(row, this.findArgs); - }); }, }); @@ -469,7 +462,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/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 af252a88..6458ed7a 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'; @@ -82,6 +82,12 @@ export class SOQLView extends LitElement { document.addEventListener('lv-find-close', this._findEvt); } + disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener('lv-find', this._findEvt); + document.removeEventListener('lv-find-close', this._findEvt); + } + updated(changedProperties: PropertyValues): void { if ( this.timelineRoot && @@ -228,7 +234,7 @@ export class SOQLView extends LitElement { }); } - _highlightMatches(highlightIndex: number) { + async _highlightMatches(highlightIndex: number) { if (!this.soqlTable?.element?.clientHeight) { return; } @@ -236,17 +242,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 +503,6 @@ export class SOQLView extends LitElement { const detailContainer = this.createSOQLDetailPanel(data.timestamp, timestampToSOQl); row.getElement().replaceChildren(detailContainer); } - - requestAnimationFrame(() => { - formatter(row, this.findArgs); - }); }, }); @@ -591,13 +587,13 @@ 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; 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/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/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; diff --git a/log-viewer/src/tabulator/module/Find.ts b/log-viewer/src/tabulator/module/Find.ts index 3bc46b2b..0025caf1 100644 --- a/log-viewer/src/tabulator/module/Find.ts +++ b/log-viewer/src/tabulator/module/Find.ts @@ -1,20 +1,100 @@ /* * 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 }; 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; + _cachedRegex: RegExp | null = null; + _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 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() { + // 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('tableDestroyed', () => { + this._clearFindHighlights(); + }); + + 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 +102,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 +113,7 @@ export class Find extends Module { row .getSubGroups() .flatMap(flattenFromGrps) - .forEach((child) => { + .forEach((child: RowComponent) => { mergedArray.push(child); }); return mergedArray; @@ -39,29 +121,44 @@ 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'}`); + const regex = this._buildRegex(findArgs); - 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(); + 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. + // 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]; @@ -71,75 +168,211 @@ export class Find extends Module { const data = row.getData(); data.highlightIndexes = []; - row.getCells().forEach((cell) => { - const elem = cell.getElement(); - const matchCount = this._countMatches(elem, findArgs, 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) { - 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 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) { + // 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(); + // 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); + } + } + }); } - 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!); + } + + _clearFindHighlights() { + this._clearInstanceRanges(); + this._findArgs = null; + this._cachedRegex = null; + this._currentMatchIndex = 0; + this._matchIndexes = {}; } - _countMatches(elem: Node, findArgs: FindArgs, regex: RegExp) { - let count = 0; + _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); + } + 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); } } - 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 +397,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); @@ -179,121 +410,127 @@ 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'); + // 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; } } - } -} -export function formatter(row: RowComponent, findArgs: FindArgs) { - const { text } = findArgs; - if (!text) { - return; - } - const data = row.getData(); - if (!data || !data.highlightIndexes?.length) { - return; + return null; } - 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(); + // 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; } -} - -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; + _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 ?? ''); + } - 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); + 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 ?? ''); + } - 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); + // 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 || fallback; } + + return result; } + + return result?.textContent || fallback; } } - -type FindArgs = { text: string; count: number; options: { matchCase: boolean } }; diff --git a/log-viewer/src/tabulator/module/RowNavigation.ts b/log-viewer/src/tabulator/module/RowNavigation.ts index 9ce3b407..d1e4524e 100644 --- a/log-viewer/src/tabulator/module/RowNavigation.ts +++ b/log-viewer/src/tabulator/module/RowNavigation.ts @@ -13,70 +13,114 @@ 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; + async 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 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(); + 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) => { + // 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) { + async _scrollToRow(row: RowComponent, opts: GoToRowOptions): Promise { const { scrollIfVisible, focusRow } = opts; - this.table.scrollToRow(row, 'center', scrollIfVisible).then(() => { - setTimeout(() => { - const elem = row.getElement(); + await this.table.scrollToRow(row, 'center', scrollIfVisible); - 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' }); - } + const elem = row.getElement(); + if (scrollIfVisible || !this._isVisible(elem)) { + this._centerRow(elem); + } - if (focusRow) { - elem.focus(); - } - }); - }); + if (focusRow) { + elem.focus(); + } } _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; + } + + // 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; } } diff --git a/log-viewer/src/tabulator/style/DataGrid.scss b/log-viewer/src/tabulator/style/DataGrid.scss index 240b3afa..256b9fc2 100644 --- a/log-viewer/src/tabulator/style/DataGrid.scss +++ b/log-viewer/src/tabulator/style/DataGrid.scss @@ -45,9 +45,12 @@ $footerActiveColor: #d00 !default; //footer bottom active text color .tabulator { .tabulator-tableholder { overflow-x: hidden; + overflow-anchor: none; + will-change: transform; .tabulator-table { display: block; background-color: default; + content-visibility: auto; } }