diff --git a/CHANGELOG.md b/CHANGELOG.md index ae80d04a..8723600e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - πŸ”΄ **Timeline exception markers**: exceptions show as red lines, with a **Throws** count in method tooltips. ([#828]) +- 🧩 **Call Tree, Analysis and Database tables** gain configurable columns and views. ([#298]) + - πŸ—‚οΈ **Column views**: switch each table between column sets (`General`, `Time`, `Governor Limits`, `Database`, `Memory`); edit a view by showing/hiding columns from the **Columns** toolbar button or the header right-click menu, with inline **reset** to restore defaults; choices persist per view. + - πŸ“Š **New columns**: **SOSL Count/Rows**, **Avg Self Time**, and optional **Self** variants for every governor metric. + - 🧠 **Heap / memory analysis**: **Heap Allocated** (+ Self) columns, surfaced through the `Memory` view and the Timeline governor limit strip. + - πŸ”Ž **SOQL Query Plan** view: Relative Cost, Leading Operation, SObject Type and Cardinality. ### Changed @@ -505,6 +510,8 @@ Skipped due to adopting odd numbering for pre releases and even number for relea [#832]: https://github.com/certinia/debug-log-analyzer/issues/832 [#848]: https://github.com/certinia/debug-log-analyzer/issues/848 [#827]: https://github.com/certinia/debug-log-analyzer/issues/827 +[#298]: https://github.com/certinia/debug-log-analyzer/issues/298 +[#32]: https://github.com/certinia/debug-log-analyzer/issues/32 diff --git a/README.md b/README.md index 84a5657c..746faa9b 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,9 @@ Also: Frame Selection & Navigation, Dynamic Frame Labels, Adaptive Frame Detail, Explore nested method calls with performance metrics: -- **Metrics**: Self Time, Total Time, SOQL/DML/Thrown Counts, SOQL/DML/Rows -- **Views**: Use Time Order for sequence, Aggregated for repeated hot paths, Bottom-Up for caller attribution +- **Metrics**: Self Time, Total Time, SOQL/DML/SOSL Counts + Rows, Heap, Governor Limit Avg + Peak, Thrown +- **Call Tree Views**: Use Time Order for sequence, Aggregated for repeated hot paths, Bottom-Up for caller attribution +- **Column Views** – Switch preset column sets (General, Time, Governor Limits, Database, Memory), show/hide columns from the header menu, reset to defaults - **Group Bottom-Up by Namespace or Type** - **Filter by Namespace, Type or Duration** - **Toggle Debug-Only + Detail Events** @@ -114,6 +115,7 @@ Explore nested method calls with performance metrics: See which methods are the slowest, most frequent. or expensive. - **Group by Type, Namespace, or Caller Namespace ** +- **Column Views** – Preset column sets, show/hide columns, reset to defaults - **Sort by Duration, Count, Name, Type or Namespace** - **Filter to specific event types** - **Copy or Export to CSV** @@ -126,6 +128,7 @@ Highlight slow Salesforce SOQL queries, non-selective filters, and DML issues. - **SOQL + DML Duration, Selectivity, Aggregates, Row Count** - **Group by Namespace, Caller Namespace or Query** +- **Column Views** – Preset column sets (incl. a SOQL Query Plan view), show/hide columns, reset to defaults - **View the Call Stack** - **SOQL Optimization Tips** - **Sort by SOQL or DML, Duration, Selectivity, Aggregates, Row Count** diff --git a/apex-log-parser/__tests__/ApexLogParser.test.ts b/apex-log-parser/__tests__/ApexLogParser.test.ts index 940b3409..3f64ec12 100644 --- a/apex-log-parser/__tests__/ApexLogParser.test.ts +++ b/apex-log-parser/__tests__/ApexLogParser.test.ts @@ -381,6 +381,32 @@ describe('parseLog tests', () => { expect(thrown?.thrownCount).toEqual({ self: 1, total: 1 }); }); + it('heapAllocated is seeded on the allocation leaf and rolled up as total on ancestors', async () => { + const log = + '09:18:22.6 (100)|EXECUTION_STARTED\n\n' + + '15:20:52.222 (200)|METHOD_ENTRY|[185]|01p4J00000FpS6t|Outer.run()\n' + + '15:20:52.222 (300)|HEAP_ALLOCATE|[52]|Bytes:10\n' + + '15:20:52.222 (400)|METHOD_ENTRY|[190]|01p4J00000FpS6u|Inner.work()\n' + + '15:20:52.222 (500)|HEAP_ALLOCATE|[60]|Bytes:32\n' + + '15:20:52.222 (600)|METHOD_EXIT|[190]|01p4J00000FpS6u|Inner.work()\n' + + '15:20:52.222 (700)|METHOD_EXIT|[185]|01p4J00000FpS6t|Outer.run()\n' + + '09:19:13.82 (2000)|EXECUTION_FINISHED\n'; + + const apexLog = parse(log); + + // children[0] is the EXECUTION_STARTED wrapper; Outer.run() is its child. + const outer = apexLog.children[0]!.children[0]!; + const inner = outer.children.find((child) => child.type === 'METHOD_ENTRY')!; + + // The allocation leaf carries self === total === bytes. + const outerAlloc = outer.children.find((child) => child.type === 'HEAP_ALLOCATE'); + expect(outerAlloc?.heapAllocated).toEqual({ self: 10, total: 10 }); + + // Inner rolls up its own 32 bytes; Outer rolls up both (10 + 32). + expect(inner.heapAllocated).toEqual({ self: 0, total: 32 }); + expect(outer.heapAllocated).toEqual({ self: 0, total: 42 }); + }); + it('Methods should have line-numbers', async () => { const log = '09:18:22.6 (0)|EXECUTION_STARTED\n\n' + diff --git a/apex-log-parser/src/ApexLogParser.ts b/apex-log-parser/src/ApexLogParser.ts index a726ec43..9507f88c 100644 --- a/apex-log-parser/src/ApexLogParser.ts +++ b/apex-log-parser/src/ApexLogParser.ts @@ -452,6 +452,7 @@ export class ApexLogParser { parent.soslRowCount.total += child.soslRowCount.total; parent.duration.self -= child.duration.total; parent.thrownCount.total += child.thrownCount.total; + parent.heapAllocated.total += child.heapAllocated.total; } } } diff --git a/apex-log-parser/src/LogEvents.ts b/apex-log-parser/src/LogEvents.ts index 5cceffd5..adf7ea2b 100644 --- a/apex-log-parser/src/LogEvents.ts +++ b/apex-log-parser/src/LogEvents.ts @@ -246,6 +246,24 @@ export abstract class LogEvent { total: 0, }; + /** + * Total + self heap bytes allocated (HEAP_ALLOCATE / BULK_HEAP_ALLOCATE). + * + * `self` is seeded on the allocation leaf node (like DML/SOQL counts), so a method's + * `heapAllocated.self` is typically 0 and `heapAllocated.total` is the sum of allocations + * in this node and its children. + */ + heapAllocated: SelfTotal = { + /** + * The net bytes allocated directly by this node. + */ + self: 0, + /** + * The total bytes allocated in this node and child nodes + */ + total: 0, + }; + /** * The line types which would legitimately end this method */ @@ -515,6 +533,7 @@ export class BulkHeapAllocateLine extends LogEvent { super(parser, parts); this.text = parts[2] || ''; this.bytes = parseBytes(parts[2]); + this.heapAllocated.self = this.heapAllocated.total = this.bytes; } } @@ -1103,6 +1122,7 @@ export class HeapAllocateLine extends LogEvent { this.lineNumber = this.parseLineNumber(parts[2]); this.text = parts[3] || ''; this.bytes = parseBytes(parts[3]); + this.heapAllocated.self = this.heapAllocated.total = this.bytes; } } diff --git a/lana-docs/docs/docs/features/analysis.md b/lana-docs/docs/docs/features/analysis.md index 047cc842..00dd7c83 100644 --- a/lana-docs/docs/docs/features/analysis.md +++ b/lana-docs/docs/docs/features/analysis.md @@ -31,6 +31,10 @@ Each column can be sorted by clicking the column header, this will sort the rows 1. Show Log events for specific namespaces using the namespace column filter +### Column Views + +The same presets as the Call Tree (General, Time, Governor Limits, Database, Memory), from the **Columns** button in the toolbar (or the header right-click menu). Show or hide individual columns there; an edited view shows a **reset** icon. Choices persist per view. + ### Group The rows can be grouped by Type, Namespace, or Caller Namespace. diff --git a/lana-docs/docs/docs/features/calltree.mdx b/lana-docs/docs/docs/features/calltree.mdx index 98c6b511..226b2d6b 100644 --- a/lana-docs/docs/docs/features/calltree.mdx +++ b/lana-docs/docs/docs/features/calltree.mdx @@ -50,6 +50,18 @@ Clicking the link in the event column will open the corresponding file and line, Each column can be sorted by clicking the column header, this will sort the rows within the tree structure e.g sorting by self time will sort the children within a parent with the largest self time to the top but only within that parent. +### Column Views + +Switch column sets from the **Columns** button in the toolbar (or the header right-click menu): + +1. **General** – everyday overview. +1. **Time** – call count and self/total/average time. +1. **Governor Limits** – DML/SOQL/SOSL counts + rows, throws, heap, and governor limit usage (avg + peak). +1. **Database** – DML/SOQL/SOSL counts + rows. +1. **Memory** – heap self and total. + +Show or hide individual columns from the same menu; an edited view shows a **reset** icon. Choices persist per view. + ### Filtering 1. Details (events with 0 time) are hidden by default but can be shown/ hidden. diff --git a/lana-docs/docs/docs/features/database.md b/lana-docs/docs/docs/features/database.md index 0b879219..6d953bc0 100644 --- a/lana-docs/docs/docs/features/database.md +++ b/lana-docs/docs/docs/features/database.md @@ -37,6 +37,10 @@ If the grouping is removed the sorting applies the same but across all rows inst 1. In the SOQL view show Log events for specific namespaces using the namespace column filter +### Column Views + +Switch column sets from the **Columns** button in the toolbar (or the header right-click menu). SOQL offers **General**, **Performance** and **Query Plan** (Relative Cost, Leading Operation, SObject Type, Cardinality); DML offers **General** and **Timing**. Show or hide individual columns there; an edited view shows a **reset** icon. Choices persist per table. + ### Group By default rows are grouped by the SOQL/ DML text, grouping can be removed and the rows shows as a flat list using the _Group by_ item in the header menu. The groups are default sorted with the groups with the most items at the top. diff --git a/lana/package.json b/lana/package.json index 2fd28a58..9a2ae2d6 100644 --- a/lana/package.json +++ b/lana/package.json @@ -138,6 +138,20 @@ "default": false, "markdownDescription": "Tint the Call Tree Name column by event category, matching the Timeline colors, instead of showing a small color chip. Default: `false`", "order": 0 + }, + "lana.callTree.columnView": { + "title": "Column view", + "type": "string", + "default": "General", + "enum": [ + "General", + "Time", + "Governor Limits", + "Database", + "Memory" + ], + "markdownDescription": "The column view applied to the Call Tree and Analysis tables. Default: `General`", + "order": 1 } } }, @@ -326,6 +340,37 @@ } } } + }, + { + "type": "object", + "id": "lana", + "title": "Database", + "order": 2, + "properties": { + "lana.database.soql.columnView": { + "title": "SOQL column view", + "type": "string", + "default": "General", + "enum": [ + "General", + "Performance", + "Query Plan" + ], + "markdownDescription": "The column view applied to the Database SOQL table. Default: `General`", + "order": 0 + }, + "lana.database.dml.columnView": { + "title": "DML column view", + "type": "string", + "default": "General", + "enum": [ + "General", + "Timing" + ], + "markdownDescription": "The column view applied to the Database DML table. Default: `General`", + "order": 2 + } + } } ] }, diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index f926ba98..0d3afc20 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -11,7 +11,13 @@ import type { Context } from '../Context.js'; import { OpenFileInPackage } from '../display/OpenFileInPackage.js'; import { WebView } from '../display/WebView.js'; import { RawLogNavigation } from '../log-features/RawLogNavigation.js'; -import { getConfig } from '../workspace/AppConfig.js'; +import { + COLUMN_OVERRIDE_SECTIONS, + getColumnOverrides, + getConfig, + updateColumnOverride, + updateConfig, +} from '../workspace/AppConfig.js'; interface WebViewLogFileRequest { requestId: string; @@ -102,14 +108,31 @@ export class LogView { } case 'getConfig': { + const config = getConfig(); + const overrides = getColumnOverrides(context.context.globalState); + config.callTree.columnOverrides = overrides['callTree.columnOverrides'] ?? {}; + config.database.soql.columnOverrides = overrides['database.soql.columnOverrides'] ?? {}; + config.database.dml.columnOverrides = overrides['database.dml.columnOverrides'] ?? {}; panel.webview.postMessage({ requestId, cmd: 'getConfig', - payload: getConfig(), + payload: config, }); break; } + case 'updateConfig': { + const { section, value } = payload as { section: string; value: unknown }; + if (section) { + if ((COLUMN_OVERRIDE_SECTIONS as readonly string[]).includes(section)) { + updateColumnOverride(context.context.globalState, section, value); + } else { + updateConfig(section, value); + } + } + break; + } + case 'saveFile': { const { fileContent, options } = payload as { fileContent: string; diff --git a/lana/src/workspace/AppConfig.ts b/lana/src/workspace/AppConfig.ts index 60b5c6d6..1c353ff3 100644 --- a/lana/src/workspace/AppConfig.ts +++ b/lana/src/workspace/AppConfig.ts @@ -2,7 +2,7 @@ * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { ConfigurationTarget, workspace } from 'vscode'; +import { ConfigurationTarget, workspace, type Memento } from 'vscode'; interface Config { timeline: { @@ -35,6 +35,12 @@ interface Config { }; callTree: { categoryColorize: boolean; + columnView: string; + columnOverrides: Record; + }; + database: { + soql: { columnView: string; columnOverrides: Record }; + dml: { columnView: string; columnOverrides: Record }; }; } @@ -60,3 +66,31 @@ export function updateConfig(section: string, value: unknown): Thenable { const config = workspace.getConfiguration('lana'); return config.update(section, value, ConfigurationTarget.Global); } + +/** + * Column overrides are opaque per-view field maps β€” private UI state, not user + * preferences β€” so they persist in globalState rather than editable settings. + */ +export const COLUMN_OVERRIDE_SECTIONS = [ + 'callTree.columnOverrides', + 'database.soql.columnOverrides', + 'database.dml.columnOverrides', +] as const; + +type ColumnOverrides = Record; + +export function getColumnOverrides(globalState: Memento): Record { + const overrides: Record = {}; + for (const section of COLUMN_OVERRIDE_SECTIONS) { + overrides[section] = globalState.get(section, {}); + } + return overrides; +} + +export function updateColumnOverride( + globalState: Memento, + section: string, + value: unknown, +): Thenable { + return globalState.update(section, value); +} diff --git a/lana/src/workspace/__tests__/AppConfig.test.ts b/lana/src/workspace/__tests__/AppConfig.test.ts new file mode 100644 index 00000000..562987b6 --- /dev/null +++ b/lana/src/workspace/__tests__/AppConfig.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it, jest } from '@jest/globals'; +import type { Memento } from 'vscode'; + +import { + COLUMN_OVERRIDE_SECTIONS, + getColumnOverrides, + updateColumnOverride, +} from '../AppConfig.js'; + +function mockMemento(store: Record = {}): Memento { + return { + keys: jest.fn(() => Object.keys(store)), + get: jest.fn((key: string, fallback?: unknown) => + key in store ? store[key] : fallback, + ) as Memento['get'], + update: jest.fn(() => Promise.resolve()), + } as unknown as Memento; +} + +describe('AppConfig column overrides', () => { + describe('getColumnOverrides', () => { + it('reads each override section, defaulting to {}', () => { + const globalState = mockMemento({ + 'callTree.columnOverrides': { Time: ['a', 'b'] }, + }); + + const overrides = getColumnOverrides(globalState); + + expect(overrides['callTree.columnOverrides']).toEqual({ Time: ['a', 'b'] }); + expect(overrides['database.soql.columnOverrides']).toEqual({}); + expect(overrides['database.dml.columnOverrides']).toEqual({}); + expect(globalState.get).toHaveBeenCalledTimes(COLUMN_OVERRIDE_SECTIONS.length); + }); + }); + + describe('updateColumnOverride', () => { + it('writes only to globalState', () => { + const globalState = mockMemento(); + const value = { Time: ['a'] }; + + updateColumnOverride(globalState, 'callTree.columnOverrides', value); + + expect(globalState.update).toHaveBeenCalledWith('callTree.columnOverrides', value); + }); + }); +}); diff --git a/log-viewer/src/components/ContextMenu.ts b/log-viewer/src/components/ContextMenu.ts index 24fab984..a465bab5 100644 --- a/log-viewer/src/components/ContextMenu.ts +++ b/log-viewer/src/components/ContextMenu.ts @@ -26,6 +26,8 @@ * ``` */ +import '#vscode-elements/vscode-icon.js'; + import { LitElement, css, html, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; @@ -40,6 +42,13 @@ export interface ContextMenuItem { separator?: boolean; /** If true, the item is grayed out and not clickable */ disabled?: boolean; + /** If true, selecting the item (or its action) leaves the menu open (multi-toggle). */ + keepOpen?: boolean; + /** + * Optional trailing action icon (e.g. per-row reset). Clicking it emits + * `menu-select` with `action.id` instead of the row's own id. + */ + action?: { id: string; icon: string; title: string }; } /** @@ -105,6 +114,15 @@ export class ContextMenu extends LitElement { font-size: 12px; } + .item-action { + margin-left: 12px; + opacity: 0.7; + } + + .item-action:hover { + opacity: 1; + } + .separator { height: 1px; margin: 6px 12px; @@ -206,8 +224,26 @@ export class ContextMenu extends LitElement { composed: true, }), ); - this.hide(); - this.dispatchEvent(new CustomEvent('menu-close', { bubbles: true, composed: true })); + if (!item.keepOpen) { + this.hide(); + this.dispatchEvent(new CustomEvent('menu-close', { bubbles: true, composed: true })); + } + } + + private handleActionClick(event: Event, actionId: string, keepOpen?: boolean): void { + // Keep the click from triggering the row's own select. + event.stopPropagation(); + this.dispatchEvent( + new CustomEvent('menu-select', { + detail: { itemId: actionId }, + bubbles: true, + composed: true, + }), + ); + if (!keepOpen) { + this.hide(); + this.dispatchEvent(new CustomEvent('menu-close', { bubbles: true, composed: true })); + } } private adjustPosition(): void { @@ -257,6 +293,18 @@ export class ContextMenu extends LitElement { > ${item.label} ${item.shortcut ? html`${item.shortcut}` : nothing} + ${ + item.action + ? html`` + : nothing + } `; } diff --git a/log-viewer/src/components/VsSelect.ts b/log-viewer/src/components/VsSelect.ts index 56dc5e11..18061d1e 100644 --- a/log-viewer/src/components/VsSelect.ts +++ b/log-viewer/src/components/VsSelect.ts @@ -1,11 +1,14 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ +import '#vscode-elements/vscode-icon.js'; + import { VscodeSingleSelect } from '#vscode-elements/vscode-single-select.js'; -import { css, html, type TemplateResult } from 'lit'; +import { css, html, nothing, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { repeat } from 'lit/directives/repeat.js'; import { selectFaceText } from './selectFaceText.js'; @@ -85,6 +88,16 @@ export const selectSizingStyles = css` .combobox-face .face-prefix { padding-left: 4px; } + + /* Rows carrying a reset affordance lay the icon out at the trailing edge. */ + .option.resettable-option { + display: flex; + align-items: center; + } + + .option-reset { + margin-left: auto; + } `; /** vscode-single-select where the control fits the selected value and the popup its widest option. */ @@ -100,6 +113,13 @@ export class VsSelect extends VscodeSingleSelect { @property({ type: String }) emptyValue = 'None'; + /** + * Option values shown as "edited": each gets a trailing reset icon (and a `β€’` + * marker on both the row and the face). Empty β†’ default select behaviour. + */ + @property({ attribute: false }) + resettableValues: string[] = []; + private _faceContent(): TemplateResult { const { prefixText, valueText } = selectFaceText({ prefix: this.prefix, @@ -107,12 +127,84 @@ export class VsSelect extends VscodeSingleSelect { value: this.value, emptyValue: this.emptyValue, }); + const marker = this.resettableValues.includes(this.value) ? ' β€’' : ''; return html`${prefixText ? html`${prefixText}` : ''}${valueText}${valueText}${marker}`; } + private _onResetOption(event: Event, value: string): void { + // Keep the click from selecting/closing the row it lives in. + event.stopPropagation(); + this.dispatchEvent( + new CustomEvent('vs-reset-option', { detail: { value }, bubbles: true, composed: true }), + ); + } + + protected override _renderOptions(): TemplateResult | TemplateResult[] { + if (this.resettableValues.length === 0) { + return super._renderOptions(); + } + + return html` +
    + ${repeat( + this._opts.options, + (op) => op.index, + (op, index) => { + if (!op.visible) { + return nothing; + } + const active = op.index === this._opts.activeIndex && !op.disabled; + const selected = this._opts.getIsIndexSelected(op.index); + const edited = this.resettableValues.includes(op.value); + const optionClasses = { + active, + disabled: op.disabled, + option: true, + 'single-select': true, + 'resettable-option': edited, + selected, + }; + return html` +
  • + ${op.label}${edited ? ' β€’' : ''} + ${ + edited + ? html` this._onResetOption(event, op.value)} + >` + : nothing + } +
  • + `; + }, + )} +
+ `; + } + protected override _renderSelectFace(): TemplateResult { const activeDescendant = this._opts.activeIndex > -1 ? `op-${this._opts.activeIndex}` : ''; const { active } = selectFaceText({ diff --git a/log-viewer/src/components/datagrid-filter-bar.ts b/log-viewer/src/components/datagrid-filter-bar.ts index a0bf8856..9e7908d8 100644 --- a/log-viewer/src/components/datagrid-filter-bar.ts +++ b/log-viewer/src/components/datagrid-filter-bar.ts @@ -1,12 +1,22 @@ /* * Copyright (c) 2021 Certinia Inc. All rights reserved. */ -import { LitElement, css, html } from 'lit'; +import { LitElement, css, html, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; // styles import { globalStyles } from '../styles/global.styles.js'; +// components +import './Divider.js'; + +/** + * Shared grid toolbar. Content is grouped into sections, left→right: + * global · table-actions · filters (left cluster) + * group · actions (right cluster, right-aligned) + * A vertical divider is drawn between adjacent non-empty sections; the flexible + * gap separates the left cluster from the right-aligned grouping/actions. + */ @customElement('datagrid-filter-bar') export class DatagridFilterBar extends LitElement { static styles = [ @@ -22,54 +32,89 @@ export class DatagridFilterBar extends LitElement { .filter-bar { display: flex; - .filter-bar__filters { - display: flex; - align-items: flex-end; - } + align-items: flex-end; + gap: 8px; + width: 100%; } - .filter-bar .filter-bar__actions--right { - align-items: flex-end; + .filter-bar__left, + .filter-bar__right { display: flex; - flex: 1 1 auto; + align-items: flex-end; gap: 8px; + } + + /* Right cluster consumes the slack and right-aligns grouping + actions. */ + .filter-bar__right { + flex: 1 1 auto; justify-content: flex-end; } - .filter-bar__group { - align-items: flex-end; + .section { display: flex; + align-items: flex-end; + gap: 4px; + } + + .section[hidden] { + display: none; } - /* Divider between the grouping control and the action buttons. */ - .filter-bar__group.has-group::after { + divider-line { align-self: stretch; - border-right: 1px solid var(--vscode-widget-border, transparent); - content: ''; - margin: 2px 8px; + margin: 2px 0; } `, ]; - @state() - private _hasGroup = false; + @state() private _hasGlobal = false; + @state() private _hasTableActions = false; + @state() private _hasFilters = false; + @state() private _hasGroup = false; + @state() private _hasActions = false; + + private _slotChange( + prop: '_hasGlobal' | '_hasTableActions' | '_hasFilters' | '_hasGroup' | '_hasActions', + ) { + return (event: Event) => { + this[prop] = (event.target as HTMLSlotElement).assignedElements().length > 0; + }; + } - private _onGroupSlotChange(event: Event) { - this._hasGroup = (event.target as HTMLSlotElement).assignedElements().length > 0; + private _divider() { + return html``; } render() { + // Dividers sit between adjacent non-empty sections within each cluster. + const tableActionsDivider = this._hasGlobal && this._hasTableActions; + const filtersDivider = this._hasFilters && (this._hasGlobal || this._hasTableActions); + const actionsDivider = this._hasGroup && this._hasActions; + + // Empty sections are hidden so they don't add stray gaps. return html`
-
- - +
+
+ +
+ ${tableActionsDivider ? this._divider() : nothing} +
+ +
+ ${filtersDivider ? this._divider() : nothing} +
+ +
-
-
- +
+
+ +
+ ${actionsDivider ? this._divider() : nothing} +
+
-
`; } diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index de8868ab..18b1f108 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -7,12 +7,25 @@ import '#vscode-elements/vscode-option.js'; import '../../../components/VsSelect.js'; import '#vscode-elements/vscode-toolbar-button.js'; import { LitElement, css, html, unsafeCSS, type PropertyValues } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; import type { RowComponent, Tabulator } from 'tabulator-tables'; import type { ApexLog } from 'apex-log-parser'; +import '../../../components/ContextMenu.js'; +import type { ContextMenu } from '../../../components/ContextMenu.js'; import { isVisible } from '../../../core/utility/Util.js'; +import { getSettings, updateSetting } from '../../settings/Settings.js'; import { createBottomUpTable } from '../../call-tree/components/BottomUpTable.js'; +import { + applyColumnView, + buildColumnMenuItems, + CALL_TREE_VIEWS, + getColumnView, + getTableFields, + resolveColumnView, + toggleField, +} from '../../../tabulator/ColumnViews.js'; import type { BottomUpRow } from '../../call-tree/utils/Aggregation.js'; import { categoryColoringStyles, @@ -31,6 +44,9 @@ import { soqlSyntaxStyles } from '../../soql/styles/soql-syntax.css.js'; import '../../../components/GridSkeleton.js'; import '../../../components/datagrid-filter-bar.js'; +/** The Name column is always shown in the analysis table. */ +const ALWAYS_VISIBLE = ['text']; + @customElement('analysis-view') export class AnalysisView extends LitElement { static styles = [ @@ -84,6 +100,14 @@ export class AnalysisView extends LitElement { timelineRoot: ApexLog | null = null; analysisTable: Tabulator | null = null; + + @state() + columnView = 'General'; + + /** Per-view column overrides (view id β†’ visible fields); empty until edited. */ + @state() + private columnOverrides: Record = {}; + private contextMenu: ContextMenu | null = null; tableContainer: HTMLDivElement | null = null; findMap: { [key: number]: RowComponent } = {}; findArgs: { text: string; count: number; options: { matchCase: boolean } } = { @@ -120,6 +144,17 @@ export class AnalysisView extends LitElement { document.removeEventListener('lv-find-close', this._findEvt); } + firstUpdated(): void { + this.contextMenu = this.renderRoot.querySelector('context-menu'); + void this._loadColumnSettings(); + } + + private async _loadColumnSettings(): Promise { + const settings = await getSettings(); + this.columnOverrides = settings.callTree?.columnOverrides ?? {}; + this._setColumnView(resolveColumnView(CALL_TREE_VIEWS, settings.callTree?.columnView)); + } + updated(changedProperties: PropertyValues): void { if ( this.timelineRoot && @@ -136,7 +171,7 @@ export class AnalysisView extends LitElement { return html`
-
+
Collapse + + ${repeat( + CALL_TREE_VIEWS, + (view) => view.id, + (view) => + html`${view.id}`, + )} + +
+ +
Details
@@ -169,6 +225,12 @@ export class AnalysisView extends LitElement {
+
+
`; } + private _handleColumnViewChange(event: Event) { + const target = event.target as HTMLInputElement; + const id = target.value || 'General'; + this._setColumnView(id); + updateSetting('callTree.columnView', id); + } + + /** Effective fields for a view id: the user override, else the built-in preset. */ + private _columnViewFields(id: string): string[] | null { + return this.columnOverrides[id] ?? getColumnView(CALL_TREE_VIEWS, id)?.fields ?? null; + } + + private _setColumnView(id: string) { + this.columnView = id; + if (this.analysisTable) { + applyColumnView(this.analysisTable, this._columnViewFields(id), ALWAYS_VISIBLE); + } + } + + /** Applies the active view and wires the header menu once the table is built. */ + private _initTableColumns(table: Tabulator) { + applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + const header = table.element.querySelector('.tabulator-header'); + header?.addEventListener('contextmenu', (event) => { + event.preventDefault(); + this._showColumnMenu(event.clientX, event.clientY); + }); + } + + private _showColumnMenu(x: number, y: number) { + if (!this.contextMenu || !this.analysisTable) { + return; + } + this.contextMenu.show( + buildColumnMenuItems( + this.analysisTable, + this.columnView, + CALL_TREE_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ), + x, + y, + ); + } + + private _openColumnMenu(event: Event) { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + this._showColumnMenu(rect.left, rect.bottom); + } + + /** Rebuilds the open column menu so checkmarks/reset icons reflect current state. */ + private _refreshColumnMenu() { + if (!this.contextMenu?.isVisible() || !this.analysisTable) { + return; + } + this.contextMenu.items = buildColumnMenuItems( + this.analysisTable, + this.columnView, + CALL_TREE_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ); + } + + private _handleColumnMenuSelect(e: CustomEvent<{ itemId: string }>) { + const { itemId } = e.detail; + const table = this.analysisTable; + if (!table) { + return; + } + if (itemId.startsWith('view:')) { + const id = itemId.slice('view:'.length); + this._setColumnView(id); + updateSetting('callTree.columnView', id); + this._refreshColumnMenu(); + return; + } + if (itemId.startsWith('col:')) { + const field = itemId.slice('col:'.length); + const fields = toggleField( + this._columnViewFields(this.columnView), + field, + getTableFields(table), + ); + this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; + applyColumnView(table, fields, ALWAYS_VISIBLE); + updateSetting('callTree.columnOverrides', this.columnOverrides); + this._refreshColumnMenu(); + return; + } + if (itemId.startsWith('reset:')) { + this._resetColumns(itemId.slice('reset:'.length)); + this._refreshColumnMenu(); + } + } + + private _onResetOption(event: CustomEvent<{ value: string }>) { + this._resetColumns(event.detail.value); + } + + /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ + private _resetColumns(id: string = this.columnView) { + const table = this.analysisTable; + if (!table || !this.columnOverrides[id]) { + return; + } + const { [id]: _removed, ...rest } = this.columnOverrides; + this.columnOverrides = rest; + if (id === this.columnView) { + applyColumnView(table, this._columnViewFields(id), ALWAYS_VISIBLE); + } + updateSetting('callTree.columnOverrides', this.columnOverrides); + } + _copyToClipboard() { this.analysisTable?.copyToClipboard('all'); } @@ -361,6 +539,7 @@ export class AnalysisView extends LitElement { }); await tableBuilt; + this._initTableColumns(this.analysisTable); } _resetFindWidget() { diff --git a/log-viewer/src/features/call-tree/components/AggregatedTable.ts b/log-viewer/src/features/call-tree/components/AggregatedTable.ts index b6e16653..c3005925 100644 --- a/log-viewer/src/features/call-tree/components/AggregatedTable.ts +++ b/log-viewer/src/features/call-tree/components/AggregatedTable.ts @@ -8,13 +8,13 @@ import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenge import { formatDuration } from '../../../core/utility/Util.js'; import MinMaxEditor from '../../../tabulator/editors/MinMax.js'; import { minMaxTreeFilter } from '../../../tabulator/filters/MinMax.js'; -import { progressFormatter } from '../../../tabulator/format/Progress.js'; import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js'; import { VirtualVerticalRenderer } from '../../../tabulator/renderer/VirtualVerticalRenderer.js'; import { toAggregatedCallTree, type AggregatedRow } from '../utils/Aggregation.js'; import { makeSumSelfTimeAllVisible } from '../utils/BottomCalcs.js'; import { commonColumnDefaults, + createGovernorMetricColumns, headerSortElement, registerTableModules, type TableCallbacks, @@ -40,8 +40,10 @@ export function createAggregatedTable( const tableRef: { current: Tabulator | undefined } = { current: undefined }; const selfTimeBottomCalc = makeSumSelfTimeAllVisible(() => tableRef.current); + const tableData = toAggregatedCallTree(rootMethod.children, rootMethod.governorLimits); + const table = new Tabulator(container, { - data: toAggregatedCallTree(rootMethod.children), + data: tableData, index: 'id', layout: 'fitColumns', placeholder: 'No Call Tree Available', @@ -65,6 +67,10 @@ export function createAggregatedTable( { title: 'Name', field: 'text', + // Sticky column parked: frozen layout fights the vertical virtual renderer. + // Re-add with _syncTableWidth in VirtualVerticalRenderer. + // frozen: true, + minWidth: 200, headerSortTristate: true, bottomCalc: () => 'Total', cssClass: 'datagrid-textarea datagrid-code-text', @@ -108,6 +114,7 @@ export function createAggregatedTable( } }, widthGrow: 5, + widthShrink: 1, }, { title: 'Namespace', @@ -125,6 +132,13 @@ export function createAggregatedTable( }, headerFilterLiveFilter: false, }, + { + title: 'Caller Namespace', + field: 'callerNamespace', + sorter: 'string', + width: 120, + visible: false, + }, { title: 'Calls', field: 'callCount', @@ -136,122 +150,8 @@ export function createAggregatedTable( headerHozAlign: 'right', bottomCalc: 'sum', }, - { - title: 'DML Count', - field: 'dmlCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 70, - minWidth: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.dmlStatements.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.dmlStatements.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxDmlStatements = rootMethod.governorLimits.dmlStatements.limit; - return cell.getValue() + (maxDmlStatements > 0 ? '/' + maxDmlStatements : ''); - }, - }, - { - title: 'SOQL Count', - field: 'soqlCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 70, - minWidth: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.soqlQueries.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.soqlQueries.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxSoql = rootMethod.governorLimits.soqlQueries.limit; - return cell.getValue() + (maxSoql > 0 ? '/' + maxSoql : ''); - }, - }, - { - title: 'Throws Count', - field: 'thrownCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 60, - hozAlign: 'right', - headerHozAlign: 'right', - bottomCalc: 'sum', - }, - { - title: 'DML Rows', - field: 'dmlRowCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.dmlRows.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.dmlRows.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxDmlRows = rootMethod.governorLimits.dmlRows.limit; - return cell.getValue() + (maxDmlRows > 0 ? '/' + maxDmlRows : ''); - }, - }, - { - title: 'SOQL Rows', - field: 'soqlRowCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.queryRows.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: rootMethod.governorLimits.queryRows.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxQueryRows = rootMethod.governorLimits.queryRows.limit; - return cell.getValue() + (maxQueryRows > 0 ? '/' + maxQueryRows : ''); - }, - }, + ...createGovernorMetricColumns(rootMethod.governorLimits), + // Time columns sit at the far right of every call-tree table. { title: 'Total Time (ms)', field: 'totalTime', @@ -298,6 +198,19 @@ export function createAggregatedTable( headerFilterLiveFilter: false, tooltip: (_event, cell) => formatDuration(cell.getValue()), }, + { + title: 'Avg Self Time (ms)', + field: 'avgSelfTime', + sorter: 'number', + headerSortTristate: true, + width: 150, + minWidth: 120, + hozAlign: 'right', + headerHozAlign: 'right', + formatter: progressFormatterMS, + formatterParams: { precision: 2, totalValue: rootMethod.duration.total }, + tooltip: (_event, cell) => formatDuration(cell.getValue()), + }, ], }); tableRef.current = table; diff --git a/log-viewer/src/features/call-tree/components/BottomUpTable.ts b/log-viewer/src/features/call-tree/components/BottomUpTable.ts index a2760c46..1587237e 100644 --- a/log-viewer/src/features/call-tree/components/BottomUpTable.ts +++ b/log-viewer/src/features/call-tree/components/BottomUpTable.ts @@ -18,6 +18,7 @@ import { soqlGroupHeader } from '../../soql/format/groupHeader.js'; import { toBottomUpTree, type BottomUpRow } from '../utils/Aggregation.js'; import { commonColumnDefaults, + createGovernorMetricColumns, headerSortElement, registerTableModules, type TableCallbacks, @@ -94,8 +95,10 @@ export function createBottomUpTable( } : {}; + const tableData = toBottomUpTree(rootMethod.children, rootMethod.governorLimits); + const tabulatorOptions = { - data: toBottomUpTree(rootMethod.children), + data: tableData, index: 'id', layout: 'fitColumns', placeholder: options.placeholder ?? 'No Call Tree Available', @@ -130,6 +133,10 @@ export function createBottomUpTable( { title: 'Name', field: 'text', + // Sticky column parked: frozen layout fights the vertical virtual renderer. + // Re-add with _syncTableWidth in VirtualVerticalRenderer. + // frozen: true, + minWidth: 200, headerSortTristate: true, bottomCalc: () => 'Total', cssClass: 'datagrid-textarea datagrid-code-text', @@ -152,6 +159,7 @@ export function createBottomUpTable( } }, widthGrow: 5, + widthShrink: 1, }, { title: 'Namespace', @@ -168,6 +176,13 @@ export function createBottomUpTable( }, headerFilterLiveFilter: false, }, + { + title: 'Caller Namespace', + field: 'callerNamespace', + sorter: 'string', + width: 120, + visible: false, + }, { title: 'Type', field: 'type', @@ -187,6 +202,8 @@ export function createBottomUpTable( headerHozAlign: 'right', bottomCalc: 'sum', }, + ...createGovernorMetricColumns(rootMethod.governorLimits), + // Time columns sit at the far right of every call-tree table. { title: 'Total Time (ms)', field: 'totalTime', @@ -231,6 +248,20 @@ export function createBottomUpTable( headerFilterLiveFilter: false, tooltip: (_event, cell) => formatDuration(cell.getValue()), }, + { + title: 'Avg Self Time (ms)', + field: 'avgSelfTime', + sorter: 'number', + headerSortTristate: true, + width: 165, + minWidth: 120, + hozAlign: 'right', + headerHozAlign: 'right', + visible: false, + formatter: progressFormatterMS, + formatterParams: { precision: 2, totalValue: rootMethod.duration.total }, + tooltip: (_event, cell) => formatDuration(cell.getValue()), + }, ], ...tabulatorOptionOverrides, }); diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index f0ee5fa3..e09731a9 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -4,6 +4,7 @@ import '#vscode-elements/vscode-button.js'; import '#vscode-elements/vscode-checkbox.js'; import '#vscode-elements/vscode-option.js'; +import '#vscode-elements/vscode-toolbar-button.js'; import '../../../components/VsSelect.js'; import { css, html, LitElement, unsafeCSS, type PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; @@ -15,6 +16,7 @@ import { eventBus } from '../../../core/events/EventBus.js'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { findEventByEventIndex } from '../../../core/utility/EventSearch.js'; import { isVisible } from '../../../core/utility/Util.js'; +import { getSettings, updateSetting } from '../../settings/Settings.js'; import type { AggregatedRow, BottomUpRow } from '../utils/Aggregation.js'; import { categoryColoringStyles, @@ -35,14 +37,27 @@ import { soqlSyntaxStyles } from '../../soql/styles/soql-syntax.css.js'; import '../../../components/ContextMenu.js'; import type { ContextMenu } from '../../../components/ContextMenu.js'; import '../../../components/GridSkeleton.js'; +import '../../../components/datagrid-filter-bar.js'; // Table creation functions import { createAggregatedTable } from './AggregatedTable.js'; import { createBottomUpTable } from './BottomUpTable.js'; +import { + applyColumnView, + buildColumnMenuItems, + CALL_TREE_VIEWS, + getColumnView, + getTableFields, + resolveColumnView, + toggleField, +} from '../../../tabulator/ColumnViews.js'; import { createTimeOrderTable } from './TimeOrderTable.js'; type ViewMode = 'time-order' | 'aggregated' | 'bottom-up'; +/** The Name column is always shown in the call-tree tables. */ +const ALWAYS_VISIBLE = ['text']; + const DEBUG_VALUE_TYPES: ReadonlySet = new Set([ 'USER_DEBUG', 'DATAWEAVE_USER_DEBUG', @@ -94,8 +109,17 @@ export class CalltreeView extends LitElement { tableContainer: HTMLDivElement | null = null; rootMethod: ApexLog | null = null; + @state() + columnView = 'General'; + + /** Per-view column overrides (view id β†’ visible fields); empty until edited. */ + @state() + private columnOverrides: Record = {}; + private contextMenu: ContextMenu | null = null; private contextMenuRow: TimeOrderRow | null = null; + /** The table whose header was right-clicked (for column-toggle actions). */ + private contextMenuTable: Tabulator | null = null; private viewSwitchEpoch = 0; get _callTreeTableWrapper(): HTMLDivElement | null { @@ -141,6 +165,13 @@ export class CalltreeView extends LitElement { firstUpdated(): void { this.contextMenu = this.renderRoot.querySelector('context-menu'); + void this._loadColumnSettings(); + } + + private async _loadColumnSettings(): Promise { + const settings = await getSettings(); + this.columnOverrides = settings.callTree?.columnOverrides ?? {}; + this._setColumnView(resolveColumnView(CALL_TREE_VIEWS, settings.callTree?.columnView)); } static styles = [ @@ -169,27 +200,12 @@ export class CalltreeView extends LitElement { position: relative; } - .header-bar { - display: flex; - gap: 10px; - align-items: flex-end; - } - .filter-container { display: flex; gap: 4px; align-items: flex-end; } - .filter-section { - display: block; - } - - /* push the grouping control to the right edge of the header bar */ - .group-end { - margin-left: auto; - } - .view-mode-buttons { display: flex; gap: 0; @@ -249,8 +265,8 @@ export class CalltreeView extends LitElement { return html`
-
-
+ +
-
+
Expand Collapse + + + ${repeat( + CALL_TREE_VIEWS, + (view) => view.id, + (view) => + html`${view.id}`, + )} +
-
+
Details ${ @@ -315,7 +352,7 @@ export class CalltreeView extends LitElement { this.viewMode === 'bottom-up' ? html` + +
+ +
+
@@ -345,7 +391,10 @@ export class CalltreeView extends LitElement {
- +
`; } @@ -468,6 +517,128 @@ export class CalltreeView extends LitElement { } } + private _handleColumnViewChange(event: Event) { + const target = event.target as HTMLInputElement; + const id = target.value || 'General'; + this._setColumnView(id); + updateSetting('callTree.columnView', id); + } + + /** Effective fields for a view id: the user override, else the built-in preset. */ + private _columnViewFields(id: string): string[] | null { + return this.columnOverrides[id] ?? getColumnView(CALL_TREE_VIEWS, id)?.fields ?? null; + } + + private get _tables(): Tabulator[] { + return [this.calltreeTable, this.aggregatedTreeTable, this.bottomUpTreeTable].filter( + (table): table is Tabulator => !!table, + ); + } + + private _setColumnView(id: string) { + this.columnView = id; + const fields = this._columnViewFields(id); + for (const table of this._tables) { + applyColumnView(table, fields, ALWAYS_VISIBLE); + } + } + + /** Applies the active view and wires the header menu once a table is built. */ + private _initTableColumns(table: Tabulator) { + applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + const header = table.element.querySelector('.tabulator-header'); + header?.addEventListener('contextmenu', (event) => { + event.preventDefault(); + this._showHeaderContextMenu(table, event.clientX, event.clientY); + }); + } + + private _showHeaderContextMenu(table: Tabulator, clientX: number, clientY: number) { + if (!this.contextMenu) { + return; + } + this.contextMenuRow = null; + this.contextMenuTable = table; + this.contextMenu.show( + buildColumnMenuItems( + table, + this.columnView, + CALL_TREE_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ), + clientX, + clientY, + ); + } + + private _openColumnMenu(event: Event) { + const table = this._getActiveTable(); + if (!table) { + return; + } + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + this._showHeaderContextMenu(table, rect.left, rect.bottom); + } + + /** Rebuilds the open column menu so checkmarks/reset icons reflect current state. */ + private _refreshColumnMenu() { + if (!this.contextMenu?.isVisible() || !this.contextMenuTable) { + return; + } + this.contextMenu.items = buildColumnMenuItems( + this.contextMenuTable, + this.columnView, + CALL_TREE_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ); + } + + private _onColumnMenuClose() { + this.contextMenuTable = null; + this.contextMenuRow = null; + } + + /** Toggles a column in the active view's override, shared across all tables. */ + private _toggleColumn(field: string) { + const table = this.contextMenuTable; + if (!table) { + return; + } + const fields = toggleField( + this._columnViewFields(this.columnView), + field, + getTableFields(table), + ); + this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; + for (const t of this._tables) { + applyColumnView(t, fields, ALWAYS_VISIBLE); + } + updateSetting('callTree.columnOverrides', this.columnOverrides); + } + + private _onResetOption(event: CustomEvent<{ value: string }>) { + this._resetColumns(event.detail.value); + } + + /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ + private _resetColumns(id: string = this.columnView) { + if (!this.columnOverrides[id]) { + return; + } + const { [id]: _removed, ...rest } = this.columnOverrides; + this.columnOverrides = rest; + if (id === this.columnView) { + // Resolve the restored fields once (identical for every table). + const fields = this._columnViewFields(id); + for (const table of this._tables) { + applyColumnView(table, fields, ALWAYS_VISIBLE); + } + } + updateSetting('callTree.columnOverrides', this.columnOverrides); + } + _handleTypeFilter(event: Event) { const target = event.target as HTMLInputElement; this.typeFilter = target.value || 'All'; @@ -710,6 +881,7 @@ export class CalltreeView extends LitElement { }); this.calltreeTable = table; await tableBuilt; + this._initTableColumns(table); } private async _renderAggregatedTree( @@ -738,6 +910,7 @@ export class CalltreeView extends LitElement { }); this.aggregatedTreeTable = table; await tableBuilt; + this._initTableColumns(table); } private async _renderBottomUpTree(container: HTMLDivElement, rootMethod: ApexLog): Promise { @@ -768,6 +941,7 @@ export class CalltreeView extends LitElement { ); this.bottomUpTreeTable = table; await tableBuilt; + this._initTableColumns(table); } private _waitForNextFrame(): Promise { @@ -844,6 +1018,29 @@ export class CalltreeView extends LitElement { } private _handleContextMenuSelect(e: CustomEvent<{ itemId: string }>): void { + const { itemId } = e.detail; + + // Column-header menu actions (see _showHeaderContextMenu). These keep the menu + // open (keepOpen), so refresh its items live and leave contextMenuTable set β€” + // it's cleared on menu-close. + if (itemId.startsWith('view:')) { + const id = itemId.slice('view:'.length); + this._setColumnView(id); + updateSetting('callTree.columnView', id); + this._refreshColumnMenu(); + return; + } + if (itemId.startsWith('col:')) { + this._toggleColumn(itemId.slice('col:'.length)); + this._refreshColumnMenu(); + return; + } + if (itemId.startsWith('reset:')) { + this._resetColumns(itemId.slice('reset:'.length)); + this._refreshColumnMenu(); + return; + } + if (!this.contextMenuRow) { return; } diff --git a/log-viewer/src/features/call-tree/components/TableShared.ts b/log-viewer/src/features/call-tree/components/TableShared.ts index ea379657..49d26d90 100644 --- a/log-viewer/src/features/call-tree/components/TableShared.ts +++ b/log-viewer/src/features/call-tree/components/TableShared.ts @@ -1,14 +1,17 @@ /* * Copyright (c) 2025 Certinia Inc. All rights reserved. */ -import { Tabulator, type RowComponent } from 'tabulator-tables'; +import type { GovernorLimits } from 'apex-log-parser'; +import { Tabulator, type ColumnDefinition, type RowComponent } from 'tabulator-tables'; +import { progressFormatter } from '../../../tabulator/format/Progress.js'; import { AnchoringPolicy } from '../../../tabulator/module/AnchoringPolicy.js'; import * as CommonModules from '../../../tabulator/module/CommonModules.js'; import { Find } from '../../../tabulator/module/Find.js'; import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js'; import { RowNavigation } from '../../../tabulator/module/RowNavigation.js'; import type { AggregatedRow, BottomUpRow } from '../utils/Aggregation.js'; +import { governorCostBreakdown, type GovernorCostRow } from '../utils/GovernorCost.js'; import type { TimeOrderRow } from '../utils/TimeOrderTree.js'; export interface TableCallbacks { @@ -45,4 +48,278 @@ export const commonColumnDefaults = { headerSortStartingDir: 'desc' as const, headerTooltip: true, headerWordWrap: true, + // Name-only flex: every column keeps its content width by default (no + // stretch, no squeeze). The Name column overrides these to absorb slack. + widthGrow: 0, + widthShrink: 0, }; + +/** Bytes β†’ human-readable (e.g. 1536 β†’ "1.5 KB"), for heap columns/tooltips. */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(1)} ${units[unit]}`; +} + +/** + * The shared "Gov. Avg (%)" column β€” the average governor consumption across all + * governors on a call path (see {@link governorCost}), rendered as a progress + * bar. Reused across all call-tree/analysis tables. `governorCost` is populated + * during tree build; the tooltip breaks the average down per metric. + */ +export function createGovernorCostColumn(governorLimits: GovernorLimits): ColumnDefinition { + const formatterParams = { precision: 0, totalValue: 100, showPercentageText: false }; + return { + title: 'Gov. Avg (%)', + field: 'governorCost', + sorter: 'number', + cssClass: 'number-cell', + width: 100, + minWidth: 80, + hozAlign: 'right', + headerHozAlign: 'right', + formatter: progressFormatter, + formatterParams, + bottomCalc: 'max', + bottomCalcFormatter: progressFormatter, + bottomCalcFormatterParams: formatterParams, + tooltip(_event, cell) { + const total = (cell.getValue() ?? 0) as number; + const breakdown = governorCostBreakdown(cell.getData() as GovernorCostRow, governorLimits); + if (!breakdown.length) { + return `${total.toFixed(1)}%`; + } + const rows = breakdown.map((m) => { + const used = m.label === 'Heap' ? formatBytes(m.used) : `${m.used}`; + const limit = m.label === 'Heap' ? formatBytes(m.limit) : `${m.limit}`; + return `${m.label} ${used}/${limit} (${m.percent.toFixed(1)}%)`; + }); + return `${total.toFixed(1)}% β€” average utilisation across all governors
${rows.join('
')}`; + }, + }; +} + +/** + * The "Gov. Peak (%)" column β€” the single tightest governor consumed on a path + * (see {@link governorCostMax}), rendered as a bar. Complements the averaged + * Gov. Avg column; hidden by default (surfaced by the Governor Limits view or a + * user toggle). The tooltip names which governor is the peak. + */ +export function createGovernorPeakColumn(governorLimits: GovernorLimits): ColumnDefinition { + const formatterParams = { precision: 0, totalValue: 100, showPercentageText: false }; + return { + title: 'Gov. Peak (%)', + field: 'governorCostMax', + visible: false, + sorter: 'number', + cssClass: 'number-cell', + width: 100, + minWidth: 80, + hozAlign: 'right', + headerHozAlign: 'right', + formatter: progressFormatter, + formatterParams, + bottomCalc: 'max', + bottomCalcFormatter: progressFormatter, + bottomCalcFormatterParams: formatterParams, + tooltip(_event, cell) { + const peak = (cell.getValue() ?? 0) as number; + const [top] = governorCostBreakdown(cell.getData() as GovernorCostRow, governorLimits); + if (!top) { + return `${peak.toFixed(1)}%`; + } + const used = top.label === 'Heap' ? formatBytes(top.used) : `${top.used}`; + const limit = top.label === 'Heap' ? formatBytes(top.limit) : `${top.limit}`; + return `Tightest single governor: ${top.label} ${used}/${limit} (${peak.toFixed(1)}%)`; + }, + }; +} + +/** + * A governor-metric column (DML/SOQL/SOSL counts & rows) rendered as a bar + * relative to its governor `limit`. Shared by all call-tree/analysis tables so + * the Total and Self variants stay consistent. Pass `visible: false` for the + * Self variants, which are hidden until a view or the user shows them. + */ +export function createGovernorColumn(opts: { + title: string; + field: string; + limit: number; + width?: number; + minWidth?: number; + visible?: boolean; +}): ColumnDefinition { + const { title, field, limit, width = 70, minWidth = 60, visible } = opts; + const formatterParams = { precision: 0, totalValue: limit, showPercentageText: false }; + return { + title, + field, + visible, + sorter: 'number', + cssClass: 'number-cell', + width, + minWidth, + hozAlign: 'right', + headerHozAlign: 'right', + formatter: progressFormatter, + formatterParams, + bottomCalc: 'sum', + bottomCalcFormatter: progressFormatter, + bottomCalcFormatterParams: formatterParams, + tooltip(_event, cell) { + const value = cell.getValue(); + return value + (limit > 0 ? '/' + limit : ''); + }, + }; +} + +/** + * The shared governor-metric column block common to every call-tree table + * (aggregated, bottom-up, time-order): the DML/SOQL/SOSL count & row columns + * (Total + hidden Self variants), the Throws Count column, the two Heap columns, + * and the Gov. Avg/Peak columns β€” in display order. Extracted so the block stays + * identical across all three tables; spread into each table's `columns` after + * its view-specific leading columns (Name, Namespace, …). + */ +export function createGovernorMetricColumns(governorLimits: GovernorLimits): ColumnDefinition[] { + return [ + createGovernorColumn({ + title: 'DML Count', + field: 'dmlCount.total', + limit: governorLimits.dmlStatements.limit, + }), + createGovernorColumn({ + title: 'DML Count (self)', + field: 'dmlCount.self', + limit: governorLimits.dmlStatements.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOQL Count', + field: 'soqlCount.total', + limit: governorLimits.soqlQueries.limit, + }), + createGovernorColumn({ + title: 'SOQL Count (self)', + field: 'soqlCount.self', + limit: governorLimits.soqlQueries.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOSL Count', + field: 'soslCount.total', + limit: governorLimits.soslQueries.limit, + }), + createGovernorColumn({ + title: 'SOSL Count (self)', + field: 'soslCount.self', + limit: governorLimits.soslQueries.limit, + visible: false, + }), + { + title: 'Throws Count', + field: 'thrownCount.total', + sorter: 'number', + cssClass: 'number-cell', + width: 60, + hozAlign: 'right', + headerHozAlign: 'right', + bottomCalc: 'sum', + }, + createGovernorColumn({ + title: 'DML Rows', + field: 'dmlRowCount.total', + limit: governorLimits.dmlRows.limit, + }), + createGovernorColumn({ + title: 'DML Rows (self)', + field: 'dmlRowCount.self', + limit: governorLimits.dmlRows.limit, + visible: false, + }), + createGovernorColumn({ + title: 'SOQL Rows', + field: 'soqlRowCount.total', + limit: governorLimits.queryRows.limit, + }), + createGovernorColumn({ + title: 'SOQL Rows (self)', + field: 'soqlRowCount.self', + limit: governorLimits.queryRows.limit, + visible: false, + }), + // SOSL rows have no governor limit (only SOSL queries is limited, to 20), + // so these are plain counts rather than progress bars against a limit. + { + title: 'SOSL Rows', + field: 'soslRowCount.total', + sorter: 'number', + cssClass: 'number-cell', + width: 70, + minWidth: 60, + hozAlign: 'right', + headerHozAlign: 'right', + bottomCalc: 'sum', + }, + { + title: 'SOSL Rows (self)', + field: 'soslRowCount.self', + visible: false, + sorter: 'number', + cssClass: 'number-cell', + width: 70, + minWidth: 60, + hozAlign: 'right', + headerHozAlign: 'right', + bottomCalc: 'sum', + }, + createHeapColumn(governorLimits), + createHeapColumn(governorLimits, 'heapAllocated.self', 'Heap (self)', false), + createGovernorCostColumn(governorLimits), + createGovernorPeakColumn(governorLimits), + ]; +} + +/** + * The shared "Heap Allocated" column β€” bytes allocated on a call path, rendered + * as a bar relative to the heap governor limit. Reused across all + * call-tree/analysis tables. Pass a `heapAllocated.self` field + `visible: false` + * for the Self variant. + */ +export function createHeapColumn( + governorLimits: GovernorLimits, + field = 'heapAllocated.total', + title = 'Heap Allocated', + visible?: boolean, +): ColumnDefinition { + const limit = governorLimits.heapSize.limit; + const formatterParams = { precision: 0, totalValue: limit, showPercentageText: false }; + return { + title, + field, + visible, + sorter: 'number', + cssClass: 'number-cell', + width: 90, + minWidth: 70, + hozAlign: 'right', + headerHozAlign: 'right', + formatter: progressFormatter, + formatterParams, + bottomCalc: 'sum', + bottomCalcFormatter: progressFormatter, + bottomCalcFormatterParams: formatterParams, + tooltip(_event, cell) { + const used = (cell.getValue() ?? 0) as number; + return limit > 0 ? `${formatBytes(used)} / ${formatBytes(limit)}` : formatBytes(used); + }, + }; +} diff --git a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts index 70195e35..4a08a49c 100644 --- a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts +++ b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts @@ -8,7 +8,6 @@ import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenge import { formatDuration } from '../../../core/utility/Util.js'; import MinMaxEditor from '../../../tabulator/editors/MinMax.js'; import { minMaxTreeFilter } from '../../../tabulator/filters/MinMax.js'; -import { progressFormatter } from '../../../tabulator/format/Progress.js'; import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js'; import { VirtualVerticalRenderer } from '../../../tabulator/renderer/VirtualVerticalRenderer.js'; import { makeSumSelfTimeAllVisible } from '../utils/BottomCalcs.js'; @@ -16,6 +15,7 @@ import { toTimeOrderTree, type TimeOrderRow } from '../utils/TimeOrderTree.js'; import { createCalltreeNameFormatter } from './CalltreeNameFormatter.js'; import { commonColumnDefaults, + createGovernorMetricColumns, headerSortElement, registerTableModules, type TableCallbacks, @@ -40,7 +40,7 @@ export function createTimeOrderTable( const excludedTypes = new Set(['SOQL_EXECUTE_BEGIN', 'DML_BEGIN']); const governorLimits = rootMethod.governorLimits; - const tableData = toTimeOrderTree(rootMethod.children); + const tableData = toTimeOrderTree(rootMethod.children, governorLimits); const nameFormatter = createCalltreeNameFormatter(excludedTypes); const tableRef: { current: Tabulator | undefined } = { current: undefined }; @@ -73,6 +73,10 @@ export function createTimeOrderTable( { title: 'Name', field: 'text', + // Sticky column parked: frozen layout fights the vertical virtual renderer. + // Re-add with _syncTableWidth in VirtualVerticalRenderer. + // frozen: true, + minWidth: 200, headerSortTristate: true, bottomCalc: () => 'Total', cssClass: 'datagrid-textarea datagrid-code-text', @@ -93,6 +97,7 @@ export function createTimeOrderTable( } }, widthGrow: 5, + widthShrink: 1, }, { title: 'Namespace', @@ -111,121 +116,14 @@ export function createTimeOrderTable( headerFilterLiveFilter: false, }, { - title: 'DML Count', - field: 'dmlCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 70, - minWidth: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: governorLimits.dmlStatements.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: governorLimits.dmlStatements.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxDmlStatements = governorLimits.dmlStatements.limit; - return cell.getValue() + (maxDmlStatements > 0 ? '/' + maxDmlStatements : ''); - }, - }, - { - title: 'SOQL Count', - field: 'soqlCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 70, - minWidth: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: governorLimits.soqlQueries.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: governorLimits.soqlQueries.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxSoql = governorLimits.soqlQueries.limit; - return cell.getValue() + (maxSoql > 0 ? '/' + maxSoql : ''); - }, - }, - { - title: 'Throws Count', - field: 'thrownCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 60, - hozAlign: 'right', - headerHozAlign: 'right', - bottomCalc: 'sum', - }, - { - title: 'DML Rows', - field: 'dmlRowCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: governorLimits.dmlRows.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: governorLimits.dmlRows.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxDmlRows = governorLimits.dmlRows.limit; - return cell.getValue() + (maxDmlRows > 0 ? '/' + maxDmlRows : ''); - }, - }, - { - title: 'SOQL Rows', - field: 'soqlRowCount.total', - sorter: 'number', - cssClass: 'number-cell', - width: 60, - bottomCalc: 'sum', - bottomCalcFormatter: progressFormatter, - bottomCalcFormatterParams: { - precision: 0, - totalValue: governorLimits.queryRows.limit, - showPercentageText: false, - }, - formatter: progressFormatter, - formatterParams: { - precision: 0, - totalValue: governorLimits.queryRows.limit, - showPercentageText: false, - }, - hozAlign: 'right', - headerHozAlign: 'right', - tooltip(_event, cell, _onRender) { - const maxQueryRows = governorLimits.queryRows.limit; - return cell.getValue() + (maxQueryRows > 0 ? '/' + maxQueryRows : ''); - }, + title: 'Caller Namespace', + field: 'callerNamespace', + sorter: 'string', + width: 120, + visible: false, }, + ...createGovernorMetricColumns(governorLimits), + // Time columns sit at the far right of every call-tree table. { title: 'Total Time (ms)', field: 'duration.total', diff --git a/log-viewer/src/features/call-tree/utils/Aggregation.ts b/log-viewer/src/features/call-tree/utils/Aggregation.ts index cbee8217..81999e4f 100644 --- a/log-viewer/src/features/call-tree/utils/Aggregation.ts +++ b/log-viewer/src/features/call-tree/utils/Aggregation.ts @@ -2,10 +2,11 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import type { LogEvent, SelfTotal } from 'apex-log-parser'; +import type { GovernorLimits, LogEvent, SelfTotal } from 'apex-log-parser'; import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; import { Multiset } from '../../../core/utility/Multiset.js'; import { EXCLUDED_DETAIL_TYPES } from './DetailsFilter.js'; +import { setGovernorCost } from './GovernorCost.js'; /** * Represents a row in the aggregated call tree view. @@ -34,12 +35,22 @@ export interface AggregatedRow { dmlCount: SelfTotal; /** Total SOQL count */ soqlCount: SelfTotal; + /** Total SOSL count */ + soslCount: SelfTotal; /** Total DML rows */ dmlRowCount: SelfTotal; /** Total SOQL rows */ soqlRowCount: SelfTotal; + /** Total SOSL rows */ + soslRowCount: SelfTotal; /** Total + self exceptions thrown */ thrownCount: SelfTotal; + /** Total + self heap bytes allocated */ + heapAllocated: SelfTotal; + /** Average governor consumption across all reported governors (0–100%). */ + governorCost: number; + /** The single tightest governor consumed on this path (0–100+%). */ + governorCostMax: number; /** Aggregated children (callees grouped by signature) */ _children?: AggregatedRow[] | null; /** References to original events for drill-down */ @@ -82,12 +93,22 @@ export interface BottomUpRow { dmlCount: SelfTotal; /** Total SOQL count */ soqlCount: SelfTotal; + /** Total SOSL count */ + soslCount: SelfTotal; /** Total DML rows */ dmlRowCount: SelfTotal; /** Total SOQL rows */ soqlRowCount: SelfTotal; + /** Total SOSL rows */ + soslRowCount: SelfTotal; /** Total + self exceptions thrown */ thrownCount: SelfTotal; + /** Total + self heap bytes allocated */ + heapAllocated: SelfTotal; + /** Average governor consumption across all reported governors (0–100%). */ + governorCost: number; + /** The single tightest governor consumed on this path (0–100+%). */ + governorCostMax: number; /** Callers (parent functions) as children - lazy loaded */ _children?: BottomUpRow[] | null; /** @@ -127,7 +148,10 @@ function getStackKey(event: LogEvent): string { * are merged together, with aggregated metrics. * Uses Multiset call-stack tracking to prevent double-counting of recursive calls. */ -export function toAggregatedCallTree(rootChildren: LogEvent[]): AggregatedRow[] { +export function toAggregatedCallTree( + rootChildren: LogEvent[], + governorLimits?: GovernorLimits, +): AggregatedRow[] { if (rootChildren.length === 0) { return []; } @@ -160,8 +184,11 @@ export function toAggregatedCallTree(rootChildren: LogEvent[]): AggregatedRow[] for (const row of rootMap.values()) { const firstInstance = row.instances[0]; const stackKey = firstInstance ? getStackKey(firstInstance) : row.key; - row._children = aggregateChildrenRecursive(row.instances, stackKey, idFor); + row._children = aggregateChildrenRecursive(row.instances, stackKey, idFor, governorLimits); calculateAverages(row); + if (governorLimits) { + setGovernorCost(row, governorLimits); + } row._hasDetailsDeep = computeHasDetailsDeep(row, row.totalTime, row.originalData.type); } @@ -177,6 +204,7 @@ function aggregateChildrenRecursive( instances: LogEvent[], parentStackKey: string, idFor: () => number, + governorLimits?: GovernorLimits, ): AggregatedRow[] | null { const childMap = new Map(); // Create a new stack for each aggregation level, starting with the parent stack key @@ -206,8 +234,11 @@ function aggregateChildrenRecursive( for (const row of childMap.values()) { const firstInstance = row.instances[0]; const stackKey = firstInstance ? getStackKey(firstInstance) : row.key; - row._children = aggregateChildrenRecursive(row.instances, stackKey, idFor); + row._children = aggregateChildrenRecursive(row.instances, stackKey, idFor, governorLimits); calculateAverages(row); + if (governorLimits) { + setGovernorCost(row, governorLimits); + } row._hasDetailsDeep = computeHasDetailsDeep(row, row.totalTime, row.originalData.type); } @@ -239,12 +270,18 @@ function addEventToAggregatedRowWithStack( row.dmlCount.total += event.dmlCount.total; row.soqlCount.self += event.soqlCount.self; row.soqlCount.total += event.soqlCount.total; + row.soslCount.self += event.soslCount.self; + row.soslCount.total += event.soslCount.total; row.dmlRowCount.self += event.dmlRowCount.self; row.dmlRowCount.total += event.dmlRowCount.total; row.soqlRowCount.self += event.soqlRowCount.self; row.soqlRowCount.total += event.soqlRowCount.total; + row.soslRowCount.self += event.soslRowCount.self; + row.soslRowCount.total += event.soslRowCount.total; row.thrownCount.self += event.thrownCount.self; row.thrownCount.total += event.thrownCount.total; + row.heapAllocated.self += event.heapAllocated.self; + row.heapAllocated.total += event.heapAllocated.total; row.instances.push(event); } @@ -276,8 +313,10 @@ function addEventToAggregatedRowWithStack( * - duration.self / duration.total * - dmlCount.self / dmlCount.total * - soqlCount.self / soqlCount.total + * - soslCount.self / soslCount.total * - dmlRowCount.self / dmlRowCount.total * - soqlRowCount.self / soqlRowCount.total + * - soslRowCount.self / soslRowCount.total * - thrownCount.self / thrownCount.total */ type FrameContext = { @@ -290,9 +329,12 @@ type FrameContext = { totalTime: number; dmlTotal: number; soqlTotal: number; + soslTotal: number; dmlRowTotal: number; soqlRowTotal: number; + soslRowTotal: number; thrownTotal: number; + heapTotal: number; }; type DfsEntry = { @@ -320,7 +362,10 @@ type DfsEntry = { * Zero-delta guards on the DML/SOQL/row/thrown accumulators avoid the no-op * `bucket.x += 0` writes that dominate logs without heavy DB work. */ -export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { +export function toBottomUpTree( + rootChildren: LogEvent[], + governorLimits?: GovernorLimits, +): BottomUpRow[] { if (rootChildren.length === 0) { return []; } @@ -356,18 +401,24 @@ export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { totalTime: node.duration.total, dmlTotal: node.dmlCount.total, soqlTotal: node.soqlCount.total, + soslTotal: node.soslCount.total, dmlRowTotal: node.dmlRowCount.total, soqlRowTotal: node.soqlRowCount.total, + soslRowTotal: node.soslRowCount.total, thrownTotal: node.thrownCount.total, + heapTotal: node.heapAllocated.total, }; if (prior) { prior.totalTime -= node.duration.total; prior.dmlTotal -= node.dmlCount.total; prior.soqlTotal -= node.soqlCount.total; + prior.soslTotal -= node.soslCount.total; prior.dmlRowTotal -= node.dmlRowCount.total; prior.soqlRowTotal -= node.soqlRowCount.total; + prior.soslRowTotal -= node.soslRowCount.total; prior.thrownTotal -= node.thrownCount.total; + prior.heapTotal -= node.heapAllocated.total; } activeByName.set(stackKey, ctx); dfs.push({ node, childIdx: 0, ctx }); @@ -381,15 +432,21 @@ export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { const selfTime = node.duration.self; const dmlSelf = node.dmlCount.self; const soqlSelf = node.soqlCount.self; + const soslSelf = node.soslCount.self; const dmlRowSelf = node.dmlRowCount.self; const soqlRowSelf = node.soqlRowCount.self; + const soslRowSelf = node.soslRowCount.self; const thrownSelf = node.thrownCount.self; + const heapSelf = node.heapAllocated.self; const totalTime = ctx.totalTime; const dmlTotal = ctx.dmlTotal; const soqlTotal = ctx.soqlTotal; + const soslTotal = ctx.soslTotal; const dmlRowTotal = ctx.dmlRowTotal; const soqlRowTotal = ctx.soqlRowTotal; + const soslRowTotal = ctx.soslRowTotal; const thrownTotal = ctx.thrownTotal; + const heapTotal = ctx.heapTotal; // Closure captures the hoisted locals; zero-delta guards skip no-op writes // for logs without heavy DB work. @@ -409,6 +466,12 @@ export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { if (soqlTotal) { b.soqlCount.total += soqlTotal; } + if (soslSelf) { + b.soslCount.self += soslSelf; + } + if (soslTotal) { + b.soslCount.total += soslTotal; + } if (dmlRowSelf) { b.dmlRowCount.self += dmlRowSelf; } @@ -421,12 +484,24 @@ export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { if (soqlRowTotal) { b.soqlRowCount.total += soqlRowTotal; } + if (soslRowSelf) { + b.soslRowCount.self += soslRowSelf; + } + if (soslRowTotal) { + b.soslRowCount.total += soslRowTotal; + } if (thrownSelf) { b.thrownCount.self += thrownSelf; } if (thrownTotal) { b.thrownCount.total += thrownTotal; } + if (heapSelf) { + b.heapAllocated.self += heapSelf; + } + if (heapTotal) { + b.heapAllocated.total += heapTotal; + } }; const top = chainIds.length - 1; @@ -480,7 +555,7 @@ export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { } } - return finalizeBuckets(rootBuckets); + return finalizeBuckets(rootBuckets, governorLimits); } /** @@ -488,20 +563,26 @@ export function toBottomUpTree(rootChildren: LogEvent[]): BottomUpRow[] { * (primary metric total-self desc, then name asc) at every level. Empty child * arrays are collapsed to null so Tabulator's dataTree renders a leaf indicator. */ -function finalizeBuckets(rootBuckets: Map): BottomUpRow[] { +function finalizeBuckets( + rootBuckets: Map, + governorLimits?: GovernorLimits, +): BottomUpRow[] { const roots = Array.from(rootBuckets.values()); for (const row of roots) { - finalizeBucketRecursive(row); + finalizeBucketRecursive(row, governorLimits); } sortBuckets(roots); return roots; } -function finalizeBucketRecursive(row: BottomUpRow): void { +function finalizeBucketRecursive(row: BottomUpRow, governorLimits?: GovernorLimits): void { calculateBottomUpAverages(row); + if (governorLimits) { + setGovernorCost(row, governorLimits); + } if (row._children && row._children.length > 0) { for (const child of row._children) { - finalizeBucketRecursive(child); + finalizeBucketRecursive(child, governorLimits); } sortBuckets(row._children); } @@ -535,9 +616,14 @@ function createEmptyAggregatedRow( avgSelfTime: 0, dmlCount: { self: 0, total: 0 }, soqlCount: { self: 0, total: 0 }, + soslCount: { self: 0, total: 0 }, dmlRowCount: { self: 0, total: 0 }, soqlRowCount: { self: 0, total: 0 }, + soslRowCount: { self: 0, total: 0 }, thrownCount: { self: 0, total: 0 }, + heapAllocated: { self: 0, total: 0 }, + governorCost: 0, + governorCostMax: 0, _children: null, instances: [], originalData: event, @@ -565,9 +651,14 @@ function createEmptyBottomUpRow( avgSelfTime: 0, dmlCount: { self: 0, total: 0 }, soqlCount: { self: 0, total: 0 }, + soslCount: { self: 0, total: 0 }, dmlRowCount: { self: 0, total: 0 }, soqlRowCount: { self: 0, total: 0 }, + soslRowCount: { self: 0, total: 0 }, thrownCount: { self: 0, total: 0 }, + heapAllocated: { self: 0, total: 0 }, + governorCost: 0, + governorCostMax: 0, _children: null, instances: [], originalData: event, diff --git a/log-viewer/src/features/call-tree/utils/GovernorCost.ts b/log-viewer/src/features/call-tree/utils/GovernorCost.ts new file mode 100644 index 00000000..23426c9f --- /dev/null +++ b/log-viewer/src/features/call-tree/utils/GovernorCost.ts @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { GovernorLimits, SelfTotal } from 'apex-log-parser'; + +/** + * Minimal per-node metric shape needed to derive the governor cost. Every + * call-tree row model (time-order, aggregated, bottom-up) satisfies this. + */ +export interface GovernorCostRow { + dmlCount: SelfTotal; + soqlCount: SelfTotal; + soslCount: SelfTotal; + dmlRowCount: SelfTotal; + soqlRowCount: SelfTotal; + soslRowCount: SelfTotal; + heapAllocated: SelfTotal; + /** + * Average governor consumption on this path (0–100%): the mean of every + * governor's own `used/limit Γ— 100`, across all governors that have a reported + * limit (untouched ones count as 0%). A path that uses 50% of query rows and + * 50% of DML statements β€” and nothing else β€” with five reported governors + * scores `(50 + 50 + 0 + 0 + 0) / 5 = 20%`. + */ + governorCost: number; + /** + * The single tightest governor consumed on this path (0–100+%): the max of + * each governor's `used/limit Γ— 100`. Flags a path near/over one specific + * limit even when its average across governors ({@link governorCost}) is low. + */ + governorCostMax: number; +} + +interface CostMetric { + label: string; + /** Reads the node's cumulative usage for this metric. */ + used: (row: GovernorCostRow) => number; + /** Reads the log's maximum for this metric. */ + limit: (limits: GovernorLimits) => number; +} + +/** + * Metrics that are attributed per call-tree node (and therefore comparable to + * their limit per path). CPU time and callouts are intentionally excluded β€” + * they are only tracked globally, not per node. SOSL rows are also excluded: + * they have no governor limit (only SOSL *queries* is limited, to 20) and do + * not count against the SOQL query-rows limit. + */ +const COST_METRICS: CostMetric[] = [ + { label: 'SOQL', used: (r) => r.soqlCount.total, limit: (l) => l.soqlQueries.limit }, + { label: 'DML', used: (r) => r.dmlCount.total, limit: (l) => l.dmlStatements.limit }, + { label: 'SOSL', used: (r) => r.soslCount.total, limit: (l) => l.soslQueries.limit }, + { label: 'SOQL Rows', used: (r) => r.soqlRowCount.total, limit: (l) => l.queryRows.limit }, + { label: 'DML Rows', used: (r) => r.dmlRowCount.total, limit: (l) => l.dmlRows.limit }, + { label: 'Heap', used: (r) => r.heapAllocated.total, limit: (l) => l.heapSize.limit }, +]; + +/** + * Average governor consumption on this path (0–100%): the mean of each + * governor's own `used/limit Γ— 100`, over every governor with a reported limit + * (limit > 0). Governors the path didn't touch count as 0% and still divide the + * total, so this measures overall governor utilisation across all of them, not + * the single tightest one. Governors never reported in the log (limit 0) are + * excluded from both the sum and the divisor. + */ +export function governorCost(row: GovernorCostRow, limits: GovernorLimits): number { + let total = 0; + let count = 0; + for (const metric of COST_METRICS) { + const limit = metric.limit(limits); + if (limit > 0) { + total += (metric.used(row) / limit) * 100; + count++; + } + } + return count > 0 ? total / count : 0; +} + +/** + * The single tightest governor consumed on this path (0–100+%): the max of each + * governor's `used/limit Γ— 100`, over governors with a reported limit. The + * "am I about to breach one specific limit" signal, complementing the averaged + * {@link governorCost}. + */ +export function governorCostMax(row: GovernorCostRow, limits: GovernorLimits): number { + let max = 0; + for (const metric of COST_METRICS) { + const limit = metric.limit(limits); + if (limit > 0) { + const percent = (metric.used(row) / limit) * 100; + if (percent > max) { + max = percent; + } + } + } + return max; +} + +export interface GovernorCostMetric { + label: string; + used: number; + limit: number; + /** This metric's own `used/limit Γ— 100` contribution to the total. */ + percent: number; +} + +/** + * Per-metric contributions to a row's governor cost β€” every consumed metric + * (used > 0 with a known limit), highest contribution first. Used to show the + * breakdown behind the summed Gov. Cost figure in the column tooltip. + */ +export function governorCostBreakdown( + row: GovernorCostRow, + limits: GovernorLimits, +): GovernorCostMetric[] { + const metrics: GovernorCostMetric[] = []; + for (const metric of COST_METRICS) { + const limit = metric.limit(limits); + const used = metric.used(row); + if (limit > 0 && used > 0) { + metrics.push({ label: metric.label, used, limit, percent: (used / limit) * 100 }); + } + } + return metrics.sort((a, b) => b.percent - a.percent); +} + +/** + * Sets {@link GovernorCostRow.governorCost} and {@link GovernorCostRow.governorCostMax} + * on a single row from its already-aggregated totals. Called from each tree + * builder at the point a row is finalized, so governor cost is computed in the + * same pass that builds the tree (no separate traversal). + */ +export function setGovernorCost(row: GovernorCostRow, limits: GovernorLimits): void { + row.governorCost = governorCost(row, limits); + row.governorCostMax = governorCostMax(row, limits); +} diff --git a/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts b/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts index ce755283..6df91415 100644 --- a/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts +++ b/log-viewer/src/features/call-tree/utils/TimeOrderTree.ts @@ -1,10 +1,11 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import type { LogEvent, SelfTotal } from 'apex-log-parser'; +import type { GovernorLimits, LogEvent, SelfTotal } from 'apex-log-parser'; import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; import { EXCLUDED_DETAIL_TYPES } from './DetailsFilter.js'; +import { setGovernorCost } from './GovernorCost.js'; /** * One row per LogEvent for the time-order view; no merging at any level. @@ -19,9 +20,16 @@ export interface TimeOrderRow { duration: SelfTotal; dmlCount: SelfTotal; soqlCount: SelfTotal; + soslCount: SelfTotal; dmlRowCount: SelfTotal; soqlRowCount: SelfTotal; + soslRowCount: SelfTotal; thrownCount: SelfTotal; + heapAllocated: SelfTotal; + /** Average governor consumption across all reported governors (0–100%). */ + governorCost: number; + /** The single tightest governor consumed on this path (0–100+%). */ + governorCostMax: number; /** * True when this row is itself a "detail" (the per-row predicate matches β€” * non-zero `duration.total`, `isParent`, `discontinuity`, or a type in @@ -37,7 +45,10 @@ export interface TimeOrderRow { * use parser-assigned eventIndex values, which are globally unique within * the parse and safe for deepFilter cache keys. */ -export function toTimeOrderTree(nodes: LogEvent[]): TimeOrderRow[] | undefined { +export function toTimeOrderTree( + nodes: LogEvent[], + governorLimits?: GovernorLimits, +): TimeOrderRow[] | undefined { const len = nodes.length; if (!len) { return undefined; @@ -65,7 +76,7 @@ export function toTimeOrderTree(nodes: LogEvent[]): TimeOrderRow[] | undefined { duration.total > 0 || discontinuity || !!(type && EXCLUDED_DETAIL_TYPES.has(type)); - return { + const row: TimeOrderRow = { id, originalData: event, _children: mappedChildren, @@ -75,11 +86,20 @@ export function toTimeOrderTree(nodes: LogEvent[]): TimeOrderRow[] | undefined { duration: event.duration, dmlCount: event.dmlCount, soqlCount: event.soqlCount, + soslCount: event.soslCount, dmlRowCount: event.dmlRowCount, soqlRowCount: event.soqlRowCount, + soslRowCount: event.soslRowCount, thrownCount: event.thrownCount, + heapAllocated: event.heapAllocated, + governorCost: 0, + governorCostMax: 0, _hasDetailsDeep: selfIsDetail || childHasDetailsDeep, }; + if (governorLimits) { + setGovernorCost(row, governorLimits); + } + return row; } const results = new Array(len); diff --git a/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts b/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts index fd5b60b3..9a9f3e83 100644 --- a/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts +++ b/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts @@ -16,11 +16,17 @@ type EventOptions = { dmlTotal?: number; soqlSelf?: number; soqlTotal?: number; + soslSelf?: number; + soslTotal?: number; dmlRowSelf?: number; dmlRowTotal?: number; soqlRowSelf?: number; soqlRowTotal?: number; + soslRowSelf?: number; + soslRowTotal?: number; thrown?: number; + heapSelf?: number; + heapTotal?: number; }; let nextTimestamp = 1; @@ -52,11 +58,12 @@ function createEvent(options: EventOptions): LogEvent { duration: { self: options.self, total: options.total }, dmlRowCount: { self: options.dmlRowSelf ?? 0, total: options.dmlRowTotal ?? 0 }, soqlRowCount: { self: options.soqlRowSelf ?? 0, total: options.soqlRowTotal ?? 0 }, - soslRowCount: { self: 0, total: 0 }, + soslRowCount: { self: options.soslRowSelf ?? 0, total: options.soslRowTotal ?? 0 }, dmlCount: { self: options.dmlSelf ?? 0, total: options.dmlTotal ?? 0 }, soqlCount: { self: options.soqlSelf ?? 0, total: options.soqlTotal ?? 0 }, - soslCount: { self: 0, total: 0 }, + soslCount: { self: options.soslSelf ?? 0, total: options.soslTotal ?? 0 }, thrownCount: { self: options.thrown ?? 0, total: options.thrown ?? 0 }, + heapAllocated: { self: options.heapSelf ?? 0, total: options.heapTotal ?? 0 }, exitTypes: [], } as unknown as LogEvent; @@ -1106,3 +1113,55 @@ describe('_hasDetailsDeep precomputation', () => { expect(limit._hasDetailsDeep).toBe(true); }); }); + +describe('SOSL rollup', () => { + it('aggregated: sums SOSL count and rows across instances', () => { + const root = createEvent({ text: 'LOG_ROOT', self: 0, total: 0, type: 'EXECUTION_STARTED' }); + // Two calls to the same signature; their SOSL metrics are summed on the row. + createEvent({ + text: 'Search', + self: 1, + total: 1, + parent: root, + soslSelf: 1, + soslTotal: 1, + soslRowSelf: 10, + soslRowTotal: 10, + }); + createEvent({ + text: 'Search', + self: 1, + total: 1, + parent: root, + soslSelf: 1, + soslTotal: 1, + soslRowSelf: 5, + soslRowTotal: 5, + }); + + const rows = toAggregatedCallTree(root.children); + const searchRow = findRowByText(rows, 'Search'); + expect(searchRow.soslCount.total).toBe(2); + expect(searchRow.soslRowCount.total).toBe(15); + }); + + it('bottom-up: attributes SOSL self/total to the callee bucket', () => { + const root = createEvent({ text: 'LOG_ROOT', self: 0, total: 0, type: 'EXECUTION_STARTED' }); + const caller = createEvent({ text: 'Caller', self: 0, total: 2, parent: root }); + createEvent({ + text: 'Callee', + self: 2, + total: 2, + parent: caller, + soslSelf: 1, + soslTotal: 1, + soslRowSelf: 7, + soslRowTotal: 7, + }); + + const rows = toBottomUpTree(root.children); + const callee = findRowByText(rows, 'Callee'); + expect(callee.soslCount.self).toBe(1); + expect(callee.soslRowCount.total).toBe(7); + }); +}); diff --git a/log-viewer/src/features/call-tree/utils/__tests__/GovernorCost.test.ts b/log-viewer/src/features/call-tree/utils/__tests__/GovernorCost.test.ts new file mode 100644 index 00000000..e1c73190 --- /dev/null +++ b/log-viewer/src/features/call-tree/utils/__tests__/GovernorCost.test.ts @@ -0,0 +1,132 @@ +/** + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; +import type { GovernorLimits } from 'apex-log-parser'; + +import { + governorCost, + governorCostBreakdown, + governorCostMax, + type GovernorCostRow, +} from '../GovernorCost.js'; + +function limits(overrides: Record = {}): GovernorLimits { + const metric = (limit: number) => ({ used: 0, limit }); + return { + soqlQueries: metric(overrides.soqlQueries ?? 100), + dmlStatements: metric(overrides.dmlStatements ?? 150), + soslQueries: metric(overrides.soslQueries ?? 20), + queryRows: metric(overrides.queryRows ?? 50000), + dmlRows: metric(overrides.dmlRows ?? 10000), + heapSize: metric(overrides.heapSize ?? 6000000), + } as unknown as GovernorLimits; +} + +function row(overrides: Partial> = {}): GovernorCostRow { + const st = (total: number) => ({ self: 0, total }); + return { + soqlCount: st(overrides.soql ?? 0), + dmlCount: st(overrides.dml ?? 0), + soslCount: st(overrides.sosl ?? 0), + soqlRowCount: st(overrides.soqlRows ?? 0), + dmlRowCount: st(overrides.dmlRows ?? 0), + soslRowCount: st(overrides.soslRows ?? 0), + heapAllocated: st(overrides.heap ?? 0), + governorCost: 0, + governorCostMax: 0, + }; +} + +// COST_METRICS has 6 entries (SOQL, DML, SOSL, SOQL Rows, DML Rows, Heap); +// limits() reports a limit for all 6, so the divisor is 6 in these tests. SOSL +// rows are excluded β€” they have no governor limit. +const REPORTED_GOVERNORS = 6; + +describe('governorCost', () => { + it('averages each governor as a percentage of its own limit, across all reported governors', () => { + // SOQL 50% + DML 10% + Heap 50%, three others 0% β†’ (50 + 10 + 50) / 6 + expect(governorCost(row({ soql: 50, dml: 15, heap: 3000000 }), limits())).toBeCloseTo( + 110 / REPORTED_GOVERNORS, + 5, + ); + }); + + it('reaches 100% only when every reported governor is maxed', () => { + const maxed = row({ + soql: 100, + dml: 150, + sosl: 20, + soqlRows: 50000, + dmlRows: 10000, + heap: 6000000, + }); + expect(governorCost(maxed, limits())).toBeCloseTo(100, 5); + }); + + it('dilutes a single maxed governor by the untouched ones', () => { + // SOQL alone at its limit β†’ 100 / 7 + expect(governorCost(row({ soql: 100 }), limits())).toBeCloseTo(100 / REPORTED_GOVERNORS, 5); + }); + + it('divides only by governors with a known limit', () => { + // Only heap has a limit β†’ average over a single governor = heap's own %. + expect( + governorCost( + row({ soql: 100, heap: 600000 }), + limits({ + soqlQueries: 0, + dmlStatements: 0, + soslQueries: 0, + queryRows: 0, + dmlRows: 0, + heapSize: 6000000, + }), + ), + ).toBe(10); + }); + + it('is 0 when nothing is consumed', () => { + expect(governorCost(row(), limits())).toBe(0); + }); +}); + +describe('governorCostMax', () => { + it('returns the single tightest governor, undiluted', () => { + // SOQL 90% is the peak among SOQL 90 / DML 10 / Heap 50. + expect(governorCostMax(row({ soql: 90, dml: 15, heap: 3000000 }), limits())).toBeCloseTo(90, 5); + }); + + it('can exceed 100% when one limit is breached', () => { + expect(governorCostMax(row({ soql: 150 }), limits())).toBeCloseTo(150, 5); + }); + + it('is 0 when nothing is consumed', () => { + expect(governorCostMax(row(), limits())).toBe(0); + }); +}); + +describe('governorCostBreakdown', () => { + it('lists each consumed metric, highest contribution first', () => { + const breakdown = governorCostBreakdown(row({ soql: 90, dml: 15, heap: 3000000 }), limits()); + expect(breakdown.map((m) => m.label)).toEqual(['SOQL', 'Heap', 'DML']); + expect(breakdown[0]).toEqual({ label: 'SOQL', used: 90, limit: 100, percent: 90 }); + }); + + it('omits metrics with no usage or no known limit', () => { + const breakdown = governorCostBreakdown(row({ soql: 5, dml: 2 }), limits({ dmlStatements: 0 })); + // dml has usage but no limit; sosl/rows/heap have no usage. + expect(breakdown.map((m) => m.label)).toEqual(['SOQL']); + }); +}); + +describe('SOSL rows', () => { + it('do not contribute to governor cost β€” they have no governor limit', () => { + // SOSL rows are not governed (only SOSL queries, to 20) and do not count + // against the SOQL query-rows limit, so a path consuming only SOSL rows + // scores 0 across every measure. + expect(governorCost(row({ soslRows: 50000 }), limits())).toBe(0); + expect(governorCostMax(row({ soslRows: 50000 }), limits())).toBe(0); + expect(governorCostBreakdown(row({ soslRows: 50000 }), limits())).toEqual([]); + }); +}); diff --git a/log-viewer/src/features/database/components/DMLView.ts b/log-viewer/src/features/database/components/DMLView.ts index c401554d..fb83a727 100644 --- a/log-viewer/src/features/database/components/DMLView.ts +++ b/log-viewer/src/features/database/components/DMLView.ts @@ -12,11 +12,22 @@ import type { ApexLog, DMLBeginLine } from 'apex-log-parser'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; import { isVisible } from '../../../core/utility/Util.js'; +import { getSettings, updateSetting } from '../../settings/Settings.js'; import { DatabaseAccess } from '../services/Database.js'; +import { + applyColumnView, + buildColumnMenuItems, + DML_VIEWS, + getColumnView, + getTableFields, + resolveColumnView, + toggleField, +} from '../../../tabulator/ColumnViews.js'; // Tabulator custom modules, imports + styles import NumberAccessor from '../../../tabulator/dataaccessor/Number.js'; import Number from '../../../tabulator/format/Number.js'; +import { progressFormatter } from '../../../tabulator/format/Progress.js'; import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js'; import { GroupChildIndent } from '../../../tabulator/groups/GroupChildIndent.js'; import { GroupSort } from '../../../tabulator/groups/GroupSort.js'; @@ -32,9 +43,14 @@ import databaseViewStyles from './DatabaseView.scss'; // web components import '../../../components/CallStack.js'; +import '../../../components/ContextMenu.js'; +import type { ContextMenu } from '../../../components/ContextMenu.js'; import '../../../components/datagrid-filter-bar.js'; import './DatabaseSection.js'; +/** The DML column is always shown in the DML table. */ +const ALWAYS_VISIBLE = ['dml']; + const groupLabelsToFields = new Map([ ['DML', 'dml'], ['Namespace', 'namespace'], @@ -68,6 +84,14 @@ export class DMLView extends LitElement { totalMatches = 0; blockClearHighlights = true; + @state() + columnView = 'General'; + + /** Per-view column overrides (view id β†’ visible fields); empty until edited. */ + @state() + private columnOverrides: Record = {}; + private contextMenu: ContextMenu | null = null; + constructor() { super(); @@ -81,6 +105,17 @@ export class DMLView extends LitElement { document.removeEventListener('lv-find-close', this._findEvt); } + firstUpdated(): void { + this.contextMenu = this.renderRoot.querySelector('context-menu'); + void this._loadColumnSettings(); + } + + private async _loadColumnSettings(): Promise { + const settings = await getSettings(); + this.columnOverrides = settings.database?.dml?.columnOverrides ?? {}; + this._setColumnView(resolveColumnView(DML_VIEWS, settings.database?.dml?.columnView)); + } + updated(changedProperties: PropertyValues): void { if ( this.timelineRoot && @@ -127,7 +162,25 @@ export class DMLView extends LitElement { + ${DML_VIEWS.map( + (view) => + html`${view.id}`, + )} + + +
+
+ `; } + private _handleColumnViewChange(event: Event) { + const id = (event.target as HTMLInputElement).value || 'General'; + this._setColumnView(id); + updateSetting('database.dml.columnView', id); + } + + /** Effective fields for a view id: the user override, else the built-in preset. */ + private _columnViewFields(id: string): string[] | null { + return this.columnOverrides[id] ?? getColumnView(DML_VIEWS, id)?.fields ?? null; + } + + private _setColumnView(id: string) { + this.columnView = id; + if (this.dmlTable) { + applyColumnView(this.dmlTable, this._columnViewFields(id), ALWAYS_VISIBLE); + } + } + + /** Applies the active view and wires the header menu once the table is built. */ + private _initTableColumns(table: Tabulator) { + applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + const header = table.element.querySelector('.tabulator-header'); + header?.addEventListener('contextmenu', (event) => { + event.preventDefault(); + this._showColumnMenu(event.clientX, event.clientY); + }); + } + + private _showColumnMenu(x: number, y: number) { + if (!this.contextMenu || !this.dmlTable) { + return; + } + this.contextMenu.show( + buildColumnMenuItems( + this.dmlTable, + this.columnView, + DML_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ), + x, + y, + ); + } + + private _openColumnMenu(event: Event) { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + this._showColumnMenu(rect.left, rect.bottom); + } + + /** Rebuilds the open column menu so checkmarks/reset icons reflect current state. */ + private _refreshColumnMenu() { + if (!this.contextMenu?.isVisible() || !this.dmlTable) { + return; + } + this.contextMenu.items = buildColumnMenuItems( + this.dmlTable, + this.columnView, + DML_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ); + } + + private _handleColumnMenuSelect(e: CustomEvent<{ itemId: string }>) { + const { itemId } = e.detail; + const table = this.dmlTable; + if (!table) { + return; + } + if (itemId.startsWith('view:')) { + const id = itemId.slice('view:'.length); + this._setColumnView(id); + updateSetting('database.dml.columnView', id); + this._refreshColumnMenu(); + return; + } + if (itemId.startsWith('col:')) { + const field = itemId.slice('col:'.length); + const fields = toggleField( + this._columnViewFields(this.columnView), + field, + getTableFields(table), + ); + this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; + applyColumnView(table, fields, ALWAYS_VISIBLE); + updateSetting('database.dml.columnOverrides', this.columnOverrides); + this._refreshColumnMenu(); + return; + } + if (itemId.startsWith('reset:')) { + this._resetColumns(itemId.slice('reset:'.length)); + this._refreshColumnMenu(); + } + } + + private _onResetOption(event: CustomEvent<{ value: string }>) { + this._resetColumns(event.detail.value); + } + + /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ + private _resetColumns(id: string = this.columnView) { + const table = this.dmlTable; + if (!table || !this.columnOverrides[id]) { + return; + } + const { [id]: _removed, ...rest } = this.columnOverrides; + this.columnOverrides = rest; + if (id === this.columnView) { + applyColumnView(table, this._columnViewFields(id), ALWAYS_VISIBLE); + } + updateSetting('database.dml.columnOverrides', this.columnOverrides); + } + _copyToClipboard() { this.dmlTable?.copyToClipboard('all'); } @@ -267,6 +441,7 @@ export class DMLView extends LitElement { _renderDMLTable(dmlTableContainer: HTMLElement, dmlLines: DMLBeginLine[]) { const dmlData: DMLRow[] = []; + const dmlRowLimit = this.timelineRoot?.governorLimits.dmlRows.limit ?? 0; let nextRowId = 0; if (dmlLines) { for (const dml of dmlLines) { @@ -373,15 +548,31 @@ export class DMLView extends LitElement { }, headerFilterLiveFilter: false, }, + { + title: 'Namespace', + field: 'namespace', + sorter: 'string', + width: 120, + visible: false, + }, { title: 'Row Count', field: 'rowCount', sorter: 'number', cssClass: 'number-cell', width: 90, - bottomCalc: 'sum', hozAlign: 'right', headerHozAlign: 'right', + formatter: progressFormatter, + formatterParams: { precision: 0, totalValue: dmlRowLimit, showPercentageText: false }, + bottomCalc: 'sum', + bottomCalcFormatter: progressFormatter, + bottomCalcFormatterParams: { + precision: 0, + totalValue: dmlRowLimit, + showPercentageText: false, + }, + tooltip: (_e, cell) => cell.getValue() + (dmlRowLimit > 0 ? '/' + dmlRowLimit : ''), }, { title: 'Time Taken (ms)', @@ -450,6 +641,9 @@ export class DMLView extends LitElement { holder.style.overflowAnchor = 'none'; //@ts-expect-error This is a custom function added in the GroupSort custom module this.dmlTable?.setSortedGroupBy('dml'); + if (this.dmlTable) { + this._initTableColumns(this.dmlTable); + } }); this.dmlTable.on('renderComplete', () => { diff --git a/log-viewer/src/features/database/components/SOQLView.ts b/log-viewer/src/features/database/components/SOQLView.ts index 49d00df9..ee661f2d 100644 --- a/log-viewer/src/features/database/components/SOQLView.ts +++ b/log-viewer/src/features/database/components/SOQLView.ts @@ -16,13 +16,25 @@ import { import type { ApexLog, SOQLExecuteBeginLine } from 'apex-log-parser'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { isVisible } from '../../../core/utility/Util.js'; +import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; import { soqlGroupHeader } from '../../soql/format/groupHeader.js'; import { soqlSyntaxStyles } from '../../soql/styles/soql-syntax.css.js'; +import { getSettings, updateSetting } from '../../settings/Settings.js'; import { DatabaseAccess } from '../services/Database.js'; +import { + applyColumnView, + buildColumnMenuItems, + getColumnView, + getTableFields, + resolveColumnView, + SOQL_VIEWS, + toggleField, +} from '../../../tabulator/ColumnViews.js'; // Tabulator custom modules, imports + styles import NumberAccessor from '../../../tabulator/dataaccessor/Number.js'; import Number from '../../../tabulator/format/Number.js'; +import { progressFormatter } from '../../../tabulator/format/Progress.js'; import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js'; import { GroupChildIndent } from '../../../tabulator/groups/GroupChildIndent.js'; import { GroupSort } from '../../../tabulator/groups/GroupSort.js'; @@ -38,10 +50,15 @@ import databaseViewStyles from './DatabaseView.scss'; // web components import '../../../components/CallStack.js'; +import '../../../components/ContextMenu.js'; +import type { ContextMenu } from '../../../components/ContextMenu.js'; import '../../../components/datagrid-filter-bar.js'; import './DatabaseSOQLDetailPanel.js'; import './DatabaseSection.js'; +/** The SOQL column is always shown in the SOQL table. */ +const ALWAYS_VISIBLE = ['soql']; + @customElement('soql-view') export class SOQLView extends LitElement { @property() @@ -59,6 +76,14 @@ export class SOQLView extends LitElement { soqlTable: Tabulator | null = null; holder: HTMLElement | null = null; table: HTMLElement | null = null; + + @state() + columnView = 'General'; + + /** Per-view column overrides (view id β†’ visible fields); empty until edited. */ + @state() + private columnOverrides: Record = {}; + private contextMenu: ContextMenu | null = null; findArgs: { text: string; count: number; options: { matchCase: boolean } } = { text: '', count: 0, @@ -85,6 +110,17 @@ export class SOQLView extends LitElement { document.removeEventListener('lv-find-close', this._findEvt); } + firstUpdated(): void { + this.contextMenu = this.renderRoot.querySelector('context-menu'); + void this._loadColumnSettings(); + } + + private async _loadColumnSettings(): Promise { + const settings = await getSettings(); + this.columnOverrides = settings.database?.soql?.columnOverrides ?? {}; + this._setColumnView(resolveColumnView(SOQL_VIEWS, settings.database?.soql?.columnView)); + } + updated(changedProperties: PropertyValues): void { if ( this.timelineRoot && @@ -130,7 +166,25 @@ export class SOQLView extends LitElement { + ${SOQL_VIEWS.map( + (view) => + html`${view.id}`, + )} + + +
+
+ `; } + private _handleColumnViewChange(event: Event) { + const id = (event.target as HTMLInputElement).value || 'General'; + this._setColumnView(id); + updateSetting('database.soql.columnView', id); + } + + /** Effective fields for a view id: the user override, else the built-in preset. */ + private _columnViewFields(id: string): string[] | null { + return this.columnOverrides[id] ?? getColumnView(SOQL_VIEWS, id)?.fields ?? null; + } + + private _setColumnView(id: string) { + this.columnView = id; + if (this.soqlTable) { + applyColumnView(this.soqlTable, this._columnViewFields(id), ALWAYS_VISIBLE); + } + } + + /** Applies the active view and wires the header menu once the table is built. */ + private _initTableColumns(table: Tabulator) { + applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + const header = table.element.querySelector('.tabulator-header'); + header?.addEventListener('contextmenu', (event) => { + event.preventDefault(); + this._showColumnMenu(event.clientX, event.clientY); + }); + } + + private _showColumnMenu(x: number, y: number) { + if (!this.contextMenu || !this.soqlTable) { + return; + } + this.contextMenu.show( + buildColumnMenuItems( + this.soqlTable, + this.columnView, + SOQL_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ), + x, + y, + ); + } + + private _openColumnMenu(event: Event) { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + this._showColumnMenu(rect.left, rect.bottom); + } + + /** Rebuilds the open column menu so checkmarks/reset icons reflect current state. */ + private _refreshColumnMenu() { + if (!this.contextMenu?.isVisible() || !this.soqlTable) { + return; + } + this.contextMenu.items = buildColumnMenuItems( + this.soqlTable, + this.columnView, + SOQL_VIEWS, + ALWAYS_VISIBLE, + Object.keys(this.columnOverrides), + ); + } + + private _handleColumnMenuSelect(e: CustomEvent<{ itemId: string }>) { + const { itemId } = e.detail; + const table = this.soqlTable; + if (!table) { + return; + } + if (itemId.startsWith('view:')) { + const id = itemId.slice('view:'.length); + this._setColumnView(id); + updateSetting('database.soql.columnView', id); + this._refreshColumnMenu(); + return; + } + if (itemId.startsWith('col:')) { + const field = itemId.slice('col:'.length); + const fields = toggleField( + this._columnViewFields(this.columnView), + field, + getTableFields(table), + ); + this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; + applyColumnView(table, fields, ALWAYS_VISIBLE); + updateSetting('database.soql.columnOverrides', this.columnOverrides); + this._refreshColumnMenu(); + return; + } + if (itemId.startsWith('reset:')) { + this._resetColumns(itemId.slice('reset:'.length)); + this._refreshColumnMenu(); + } + } + + private _onResetOption(event: CustomEvent<{ value: string }>) { + this._resetColumns(event.detail.value); + } + + /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ + private _resetColumns(id: string = this.columnView) { + const table = this.soqlTable; + if (!table || !this.columnOverrides[id]) { + return; + } + const { [id]: _removed, ...rest } = this.columnOverrides; + this.columnOverrides = rest; + if (id === this.columnView) { + applyColumnView(table, this._columnViewFields(id), ALWAYS_VISIBLE); + } + updateSetting('database.soql.columnOverrides', this.columnOverrides); + } + _copyToClipboard() { this.soqlTable?.copyToClipboard('all'); } @@ -266,6 +441,7 @@ export class SOQLView extends LitElement { _renderSOQLTable(soqlTableContainer: HTMLElement, soqlLines: SOQLExecuteBeginLine[]) { const eventIndexToSOQL = new Map(); + const queryRowLimit = this.timelineRoot?.governorLimits.queryRows.limit ?? 0; let nextRowId = 0; soqlLines?.forEach((line) => { @@ -282,9 +458,15 @@ export class SOQLView extends LitElement { relativeCost: explainLine?.relativeCost, soql: soql.text, namespace: soql.namespace, + callerNamespace: getCallerNamespace(soql), rowCount: soql.soqlRowCount.self, timeTaken: soql.duration.total, aggregations: soql.aggregations, + leadingOperationType: explainLine?.leadingOperationType ?? null, + sObjectType: explainLine?.sObjectType ?? null, + cardinality: explainLine?.cardinality ?? null, + sObjectCardinality: explainLine?.sObjectCardinality ?? null, + fields: explainLine?.fields?.join(', ') ?? null, eventIndex: soql.eventIndex, _children: [ { @@ -379,7 +561,7 @@ export class SOQLView extends LitElement { }, width: 40, hozAlign: 'center', - vertAlign: 'middle', + vertAlign: 'top', sorter: function (a, b, aRow, bRow, _column, dir, _sorterParams) { // Always Sort null values to the bottom (when we do not have selectivity) if (a === null) { @@ -444,6 +626,13 @@ export class SOQLView extends LitElement { }, headerFilterLiveFilter: false, }, + { + title: 'Caller Namespace', + field: 'callerNamespace', + sorter: 'string', + width: 120, + visible: false, + }, { title: 'Row Count', field: 'rowCount', @@ -452,8 +641,79 @@ export class SOQLView extends LitElement { width: 100, hozAlign: 'right', headerHozAlign: 'right', + formatter: progressFormatter, + formatterParams: { precision: 0, totalValue: queryRowLimit, showPercentageText: false }, bottomCalc: 'sum', + bottomCalcFormatter: progressFormatter, + bottomCalcFormatterParams: { + precision: 0, + totalValue: queryRowLimit, + showPercentageText: false, + }, + tooltip: (_e, cell) => cell.getValue() + (queryRowLimit > 0 ? '/' + queryRowLimit : ''), + }, + { + title: 'Aggregations', + field: 'aggregations', + sorter: 'number', + cssClass: 'number-cell', + width: 100, + hozAlign: 'right', + headerHozAlign: 'right', + bottomCalc: 'sum', + }, + { + title: 'Relative Cost', + field: 'relativeCost', + sorter: 'number', + cssClass: 'number-cell', + width: 110, + hozAlign: 'right', + headerHozAlign: 'right', + visible: false, + }, + { + title: 'Leading Operation', + field: 'leadingOperationType', + sorter: 'string', + width: 140, + visible: false, + }, + { + title: 'SObject Type', + field: 'sObjectType', + sorter: 'string', + width: 130, + visible: false, + }, + { + title: 'Cardinality', + field: 'cardinality', + sorter: 'number', + cssClass: 'number-cell', + width: 110, + hozAlign: 'right', + headerHozAlign: 'right', + visible: false, }, + { + title: 'SObject Cardinality', + field: 'sObjectCardinality', + sorter: 'number', + cssClass: 'number-cell', + width: 140, + hozAlign: 'right', + headerHozAlign: 'right', + visible: false, + }, + { + title: 'Indexed Fields', + field: 'fields', + sorter: 'string', + width: 140, + visible: false, + }, + // Time column sits at the far right. { title: 'Time Taken (ms)', field: 'timeTaken', @@ -472,16 +732,6 @@ export class SOQLView extends LitElement { bottomCalc: 'sum', bottomCalcFormatterParams: { precision: 2 }, }, - { - title: 'Aggregations', - field: 'aggregations', - sorter: 'number', - cssClass: 'number-cell', - width: 100, - hozAlign: 'right', - headerHozAlign: 'right', - bottomCalc: 'sum', - }, ], rowFormatter: (row) => { const data = row.getData(); @@ -531,6 +781,9 @@ export class SOQLView extends LitElement { holder.style.overflowAnchor = 'none'; //@ts-expect-error This is a custom function added in the GroupSort custom module this.soqlTable?.setSortedGroupBy('soql'); + if (this.soqlTable) { + this._initTableColumns(this.soqlTable); + } }); this.soqlTable.on('dataSorted', () => { @@ -641,9 +894,15 @@ interface GridSOQLData { relativeCost?: number | null; soql?: string; namespace?: string; + callerNamespace?: string; rowCount?: number | null; timeTaken?: number | null; aggregations?: number; + leadingOperationType?: string | null; + sObjectType?: string | null; + cardinality?: number | null; + sObjectCardinality?: number | null; + fields?: string | null; eventIndex?: number; isDetail?: boolean; _children?: GridSOQLData[]; diff --git a/log-viewer/src/features/settings/Settings.ts b/log-viewer/src/features/settings/Settings.ts index 8d49621f..97c57779 100644 --- a/log-viewer/src/features/settings/Settings.ts +++ b/log-viewer/src/features/settings/Settings.ts @@ -31,6 +31,12 @@ export type LanaSettings = { }; callTree: { categoryColorize: boolean; + columnView: string; + columnOverrides: Record; + }; + database: { + soql: { columnView: string; columnOverrides: Record }; + dml: { columnView: string; columnOverrides: Record }; }; }; @@ -39,3 +45,8 @@ export function getSettings(): Promise { return msg; }); } + +/** Persists a `lana.*` setting via the extension (fire-and-forget). */ +export function updateSetting(section: string, value: unknown): void { + vscodeMessenger.send('updateConfig', { section, value }); +} diff --git a/log-viewer/src/features/timeline/optimised/apex-limit-series.ts b/log-viewer/src/features/timeline/optimised/apex-limit-series.ts index 35d117ce..b2cc5d80 100644 --- a/log-viewer/src/features/timeline/optimised/apex-limit-series.ts +++ b/log-viewer/src/features/timeline/optimised/apex-limit-series.ts @@ -160,13 +160,14 @@ export function buildApexLimitTimeSeries( case 'CALLOUT_REQUEST': pushDelta(timestamp, namespace, 'callouts', 1); break; + // A deallocation is normally a negative HEAP_ALLOCATE (|Bytes:-N|), so add as-is; + // HEAP_DEALLOCATE is a rarer distinct form. Each is one event type, so applying + // both cases never double-subtracts a free. case 'HEAP_ALLOCATE': case 'BULK_HEAP_ALLOCATE': - // Allocation bytes can be negative in the log, so add as-is (a negative lowers heap). pushDelta(timestamp, namespace, 'heapSize', (event as HeapAllocateLine).bytes); break; case 'HEAP_DEALLOCATE': - // Deallocation always takes away. pushDelta(timestamp, namespace, 'heapSize', -Math.abs((event as HeapAllocateLine).bytes)); break; case 'LIMIT_USAGE': diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.test.ts b/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.test.ts index 69a7d1bf..6260d179 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.test.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.test.ts @@ -9,13 +9,16 @@ const METRICS = new Map([ ['soqlQueries', { id: 'soqlQueries', displayName: 'SOQL Queries', unit: '', priority: 1 }], ['queryRows', { id: 'queryRows', displayName: 'Query Rows', unit: '', priority: 2 }], ['cpuTime', { id: 'cpuTime', displayName: 'CPU Time', unit: 'ms', priority: 0 }], + ['heapSize', { id: 'heapSize', displayName: 'Heap Size', unit: 'bytes', priority: 3 }], ]); // Authoritative fixed limits. soqlQueries β†’ threshold 1 (per-event); queryRows β†’ threshold 100. +// heapSize β†’ threshold floor(6000000/500) = 12000, so heap deltas below use β‰₯12000 magnitudes. const LIMITS = new Map([ ['soqlQueries', 100], ['queryRows', 50000], ['cpuTime', 10000], + ['heapSize', 6000000], ]); const delta = ( @@ -118,3 +121,44 @@ describe('buildGovernorTimeSeries', () => { expect(series.events.length).toBe(5); }); }); + +// Heap deltas can go negative (a deallocation is a negative HEAP_ALLOCATE), and its +// cumulative "Maximum heap size" correction is often the only source (FINE logs emit +// no HEAP_ALLOCATE events). +describe('buildGovernorTimeSeries β€” heap reconciliation', () => { + const heapUsed = (obs: LimitObservation[]) => + build(obs).events.map((e) => e.values.get('heapSize')?.used); + const heapTracked = (obs: LimitObservation[]) => + build(obs).events.map((e) => e.values.get('heapSize')?.tracked); + + it('falls on a negative delta (deallocation via negative HEAP_ALLOCATE)', () => { + const obs = [ + delta(10, 'heapSize', 100000), + delta(20, 'heapSize', 50000), + delta(30, 'heapSize', -40000), + ]; + expect(heapUsed(obs)).toEqual([100000, 150000, 110000]); + expect(heapTracked(obs)).toEqual([undefined, undefined, undefined]); + }); + + it('re-baselines to the authoritative cumulative, then keeps tracking, flagging divergence', () => { + const obs = [ + delta(10, 'heapSize', 50000), + absolute(20, 'heapSize', 200000), // peak > tracked 50000: snaps up, then +30000 + delta(30, 'heapSize', 30000), + ]; + expect(heapUsed(obs)).toEqual([50000, 200000, 230000]); + // Our lower observed count is surfaced (grey) once cumulative-anchored. + expect(heapTracked(obs)).toEqual([undefined, 50000, 80000]); + }); + + it('steps to the cumulative with no divergence when there are no HEAP_ALLOCATE events (FINE log)', () => { + const obs = [ + absolute(10, 'heapSize', 0), + absolute(20, 'heapSize', 219591), + absolute(30, 'heapSize', 219591), + ]; + expect(heapUsed(obs)).toEqual([0, 219591, 219591]); + expect(heapTracked(obs)).toEqual([undefined, undefined, undefined]); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.ts b/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.ts index 7cedb45d..e006248f 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.ts @@ -9,13 +9,16 @@ * time series for the metric strip. Boundary-safe: no parser imports. * * Model (per namespace, per metric): - * - `tracked` β€” increment-only running total from detailed events we count. Never corrected. + * - `tracked` β€” running total of counted deltas. Counters only increment; heap deltas may go + * negative (a deallocation is a negative HEAP_ALLOCATE), so the heap line can fall. * - `displayed` = baseline + (tracked βˆ’ trackedAtBaseline), the value drawn on the line. * * Two observation kinds: * - `delta` β€” adds to `tracked` (and therefore to `displayed`); marks the metric delta-tracked. * - `absolute` β€” corrective. Sets the baseline to `max(reported, displayed)`. The `max` floor keeps * monotonic counters from dipping when a subset-scoped report or fluctuating heap comes in low. + * For heap, `reported` is the authoritative cumulative "Maximum heap size" (a peak, often our only + * source since FINE logs emit no HEAP_ALLOCATE events). * * At each observation timestamp a single combined point is emitted, summing the last-known * per-namespace values (carry-forward step merge). `tracked` is surfaced on the emitted value only diff --git a/log-viewer/src/tabulator/ColumnViews.ts b/log-viewer/src/tabulator/ColumnViews.ts new file mode 100644 index 00000000..9f902c00 --- /dev/null +++ b/log-viewer/src/tabulator/ColumnViews.ts @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { Tabulator } from 'tabulator-tables'; + +import type { ContextMenuItem } from '../components/ContextMenu.js'; + +/** + * A preconfigured set of columns tailored to a kind of investigation. Column + * membership is keyed by Tabulator `field`. Presets list every applicable field + * name; fields absent from a given table are ignored by {@link applyColumnView}, + * so one preset works across differently-shaped tables (e.g. the three call-tree + * modes name equivalent columns differently: `duration.total` vs `totalTime`). + */ +export interface ColumnView { + /** Stable identifier, persisted in settings and shown in the UI. */ + id: string; + /** The fields visible in this view, or `null` to show every column. */ + fields: string[] | null; +} + +// Count/row limit metrics as their Total-variant fields. Self variants +// (`*.self`) exist as hidden columns and are surfaced only by the Memory view +// (heap) or a user override. +const LIMIT_COUNT_TOTALS = ['dmlCount.total', 'soqlCount.total', 'soslCount.total']; +const LIMIT_ROW_TOTALS = ['dmlRowCount.total', 'soqlRowCount.total', 'soslRowCount.total']; + +/** + * Column views for the Call Tree and Analysis tables. General is an explicit + * curated set (not `null`) so the Self columns stay hidden by default. Time + * fields are listed under both name variants (`totalTime`/`totalSelfTime` for + * aggregated & bottom-up, `duration.total`/`duration.self` for time-order) so + * one list works across all three tables. + */ +export const CALL_TREE_VIEWS: ColumnView[] = [ + { + // SOSL is omitted here (near-always-zero for most orgs); it stays in the + // Governor Limits and Database views and remains toggleable anywhere. + id: 'General', + fields: [ + 'namespace', + 'callCount', + 'dmlCount.total', + 'soqlCount.total', + 'thrownCount.total', + 'dmlRowCount.total', + 'soqlRowCount.total', + 'heapAllocated.total', + 'totalTime', + 'totalSelfTime', + 'duration.total', + 'duration.self', + 'governorCost', + ], + }, + { + id: 'Time', + fields: [ + 'namespace', + 'callCount', + 'totalTime', + 'totalSelfTime', + 'avgSelfTime', + 'duration.total', + 'duration.self', + 'governorCost', + ], + }, + { + id: 'Governor Limits', + fields: [ + 'namespace', + ...LIMIT_COUNT_TOTALS, + ...LIMIT_ROW_TOTALS, + 'thrownCount.total', + 'heapAllocated.total', + 'governorCost', + 'governorCostMax', + ], + }, + { + id: 'Database', + fields: ['namespace', ...LIMIT_COUNT_TOTALS, ...LIMIT_ROW_TOTALS, 'governorCost'], + }, + { + id: 'Memory', + fields: ['namespace', 'heapAllocated.self', 'heapAllocated.total', 'governorCost'], + }, +]; + +/** Column views for the SOQL database table. */ +export const SOQL_VIEWS: ColumnView[] = [ + { id: 'General', fields: ['isSelective', 'namespace', 'rowCount', 'timeTaken', 'aggregations'] }, + { id: 'Performance', fields: ['isSelective', 'relativeCost', 'rowCount', 'timeTaken'] }, + { + id: 'Query Plan', + fields: ['relativeCost', 'leadingOperationType', 'sObjectType', 'cardinality'], + }, +]; + +/** Column views for the DML database table. */ +export const DML_VIEWS: ColumnView[] = [ + { id: 'General', fields: ['callerNamespace', 'rowCount', 'timeTaken'] }, + { id: 'Timing', fields: ['rowCount', 'timeTaken'] }, +]; + +export function getColumnView(views: ColumnView[], id: string): ColumnView | undefined { + return views.find((view) => view.id === id); +} + +/** + * The persisted view id if it still matches a known view, else the first + * (default) view. Guards against a stale setting after a preset is renamed or + * removed β€” falling back to a curated view rather than showing every column. + */ +export function resolveColumnView(views: ColumnView[], id: string | undefined): string { + return views.find((view) => view.id === id)?.id ?? views[0]!.id; +} + +/** + * Shows/hides table columns to match `fields`. `null` shows every column + * (the General view). `alwaysVisible` fields are shown regardless. Fields that + * don't exist in this table are ignored, so one preset works across tables with + * differing column sets. + */ +export function applyColumnView( + table: Tabulator, + fields: string[] | null, + alwaysVisible: string[], +): void { + const visible = fields === null ? null : new Set([...alwaysVisible, ...fields]); + for (const column of table.getColumns()) { + const field = column.getField(); + if (!field) { + continue; + } + if (visible === null || visible.has(field)) { + column.show(); + } else { + column.hide(); + } + } + // Stock tabulator: column.show()/hide() flip cell visibility but do NOT re-run + // the fitColumns width distribution or normalizeHeight β€” the flex (Name) column + // wouldn't reclaim space freed by hidden columns and wrapped-row heights would + // go stale. redraw() runs layoutRefresh (widths + heights) and re-renders the + // visible window. Required β€” do not remove. + table.redraw(); +} + +/** The field names of every column in the table, in column order. */ +export function getTableFields(table: Tabulator): string[] { + return table + .getColumns() + .map((column) => column.getField()) + .filter((field): field is string => !!field); +} + +/** The fields currently visible in the table. */ +export function getVisibleFields(table: Tabulator): string[] { + return table + .getColumns() + .filter((column) => column.isVisible() && column.getField()) + .map((column) => column.getField()); +} + +/** + * Toggles `field` within a view's effective field list, returning the new + * explicit list. A `null` list (show-all) is first materialised to every table + * field so the toggle removes exactly one column. Operating on the field list + * (not a snapshot of one table's visible columns) preserves fields absent from + * the current table but present in others β€” vital for the shared call-tree lens, + * since Bottom-Up lacks the DML/SOQL columns. + */ +export function toggleField( + effectiveFields: string[] | null, + field: string, + tableFields: string[], +): string[] { + const base = effectiveFields === null ? [...tableFields] : [...effectiveFields]; + const index = base.indexOf(field); + if (index >= 0) { + base.splice(index, 1); + } else { + base.push(field); + } + return base; +} + +// Leading glyphs mark checked/unchecked menu rows; the em-space keeps unchecked +// labels aligned with checked ones (ContextMenuItem has no `checked` field). +const CHECKED = 'βœ“ '; +const UNCHECKED = ' '; + +/** + * Builds the column-header context menu: the preset views (active one ticked, + * edited ones carrying an inline reset icon), then a per-column visibility + * toggle for every column except the always-visible ones. + */ +export function buildColumnMenuItems( + table: Tabulator, + activeViewId: string, + views: ColumnView[], + alwaysVisible: string[], + editedViewIds: string[], +): ContextMenuItem[] { + const items: ContextMenuItem[] = views.map((view) => ({ + id: `view:${view.id}`, + label: `${activeViewId === view.id ? CHECKED : UNCHECKED}${view.id}`, + keepOpen: true, + ...(editedViewIds.includes(view.id) + ? { action: { id: `reset:${view.id}`, icon: 'discard', title: `Reset ${view.id} columns` } } + : {}), + })); + + items.push({ id: 'view-sep', label: '', separator: true }); + + for (const column of table.getColumns()) { + const field = column.getField(); + if (!field || alwaysVisible.includes(field)) { + continue; + } + const title = String(column.getDefinition().title ?? field); + items.push({ + id: `col:${field}`, + label: `${column.isVisible() ? CHECKED : UNCHECKED}${title}`, + keepOpen: true, + }); + } + + return items; +} diff --git a/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts b/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts new file mode 100644 index 00000000..2e3de171 --- /dev/null +++ b/log-viewer/src/tabulator/__tests__/ColumnViews.test.ts @@ -0,0 +1,242 @@ +/** + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; +import type { Tabulator } from 'tabulator-tables'; + +import { + applyColumnView, + buildColumnMenuItems, + CALL_TREE_VIEWS, + DML_VIEWS, + getColumnView, + getTableFields, + getVisibleFields, + resolveColumnView, + SOQL_VIEWS, + toggleField, +} from '../ColumnViews.js'; + +class FakeColumn { + visible = true; + field: string; + constructor(field: string) { + this.field = field; + } + getField(): string { + return this.field; + } + isVisible(): boolean { + return this.visible; + } + show(): void { + this.visible = true; + } + hide(): void { + this.visible = false; + } + getDefinition(): { title: string } { + return { title: this.field }; + } +} + +function fakeTable(fields: string[]): { table: Tabulator; columns: FakeColumn[] } { + const columns = fields.map((f) => new FakeColumn(f)); + const table = { getColumns: () => columns, redraw: () => {} } as unknown as Tabulator; + return { table, columns }; +} + +const ALWAYS_VISIBLE = ['text']; + +const CALL_TREE_FIELDS = [ + 'text', + 'namespace', + 'callCount', + 'dmlCount.total', + 'soqlCount.total', + 'thrownCount.total', + 'dmlRowCount.total', + 'soqlRowCount.total', + 'totalTime', + 'totalSelfTime', + 'heapAllocated.total', + 'governorCost', +]; + +describe('applyColumnView', () => { + it('General (null) shows every column', () => { + const { table, columns } = fakeTable(CALL_TREE_FIELDS); + columns[3]!.hide(); + applyColumnView(table, null, ALWAYS_VISIBLE); + expect(columns.every((c) => c.isVisible())).toBe(true); + }); + + it('keeps the always-visible column shown even when not listed', () => { + const { table, columns } = fakeTable(CALL_TREE_FIELDS); + applyColumnView(table, ['soqlCount.total'], ALWAYS_VISIBLE); + expect(columns.find((c) => c.getField() === 'text')!.isVisible()).toBe(true); + }); + + it('shows only listed fields (plus always-visible) and hides the rest', () => { + const { table } = fakeTable(CALL_TREE_FIELDS); + applyColumnView( + table, + ['soqlCount.total', 'soqlRowCount.total', 'governorCost'], + ALWAYS_VISIBLE, + ); + expect(getVisibleFields(table).sort()).toEqual( + ['governorCost', 'soqlCount.total', 'soqlRowCount.total', 'text'].sort(), + ); + }); + + it('ignores preset fields absent from this table', () => { + // Bottom-up lacks the count columns; the Governor Limits preset must not error. + const { table } = fakeTable([ + 'text', + 'type', + 'totalTime', + 'heapAllocated.total', + 'governorCost', + ]); + applyColumnView( + table, + getColumnView(CALL_TREE_VIEWS, 'Governor Limits')!.fields, + ALWAYS_VISIBLE, + ); + expect(getVisibleFields(table).sort()).toEqual( + ['governorCost', 'heapAllocated.total', 'text'].sort(), + ); + }); + + it('honours a non-default always-visible field (DB tables)', () => { + const { table } = fakeTable(['soql', 'isSelective', 'rowCount', 'timeTaken']); + applyColumnView(table, ['rowCount'], ['soql']); + expect(getVisibleFields(table).sort()).toEqual(['rowCount', 'soql'].sort()); + }); +}); + +describe('toggleField', () => { + it('materialises a null (show-all) view then removes the toggled field', () => { + const result = toggleField(null, 'namespace', CALL_TREE_FIELDS); + expect(result).not.toContain('namespace'); + expect(result).toContain('text'); + expect(result.length).toBe(CALL_TREE_FIELDS.length - 1); + }); + + it('removes a field already present in the list', () => { + expect(toggleField(['a', 'b', 'c'], 'b', ['a', 'b', 'c'])).toEqual(['a', 'c']); + }); + + it('adds a field not in the list', () => { + expect(toggleField(['a'], 'b', ['a', 'b'])).toEqual(['a', 'b']); + }); + + it('preserves fields absent from the current table (shared call-tree lens)', () => { + // Editing on Bottom-Up (no DML/SOQL columns) must keep those fields in the list. + const govFields = getColumnView(CALL_TREE_VIEWS, 'Governor Limits')!.fields!; + const bottomUpFields = ['text', 'type', 'totalTime', 'governorCost']; + const result = toggleField(govFields, 'governorCost', bottomUpFields); + expect(result).toContain('dmlCount.total'); + expect(result).not.toContain('governorCost'); + }); +}); + +describe('buildColumnMenuItems', () => { + it('lists views and per-column toggles, with no standalone Reset item', () => { + const { table } = fakeTable(['text', 'namespace', 'governorCost']); + const items = buildColumnMenuItems(table, 'General', CALL_TREE_VIEWS, ALWAYS_VISIBLE, []); + expect(items.some((i) => i.id === 'view:General')).toBe(true); + expect(items.some((i) => i.id === 'col:namespace')).toBe(true); + // Always-visible column has no toggle. + expect(items.some((i) => i.id === 'col:text')).toBe(false); + // Reset is now an inline per-view action, not a standalone item. + expect(items.some((i) => i.id === 'reset')).toBe(false); + }); + + it('adds an inline reset action only to edited view rows', () => { + const { table } = fakeTable(['text', 'namespace']); + const items = buildColumnMenuItems(table, 'Time', CALL_TREE_VIEWS, ALWAYS_VISIBLE, ['Time']); + expect(items.find((i) => i.id === 'view:Time')?.action?.id).toBe('reset:Time'); + expect(items.find((i) => i.id === 'view:General')?.action).toBeUndefined(); + }); +}); + +describe('resolveColumnView', () => { + it('returns the id when it matches a known view', () => { + expect(resolveColumnView(CALL_TREE_VIEWS, 'Governor Limits')).toBe('Governor Limits'); + }); + + it('falls back to the first view for an unknown or missing id', () => { + expect(resolveColumnView(CALL_TREE_VIEWS, 'Renamed')).toBe(CALL_TREE_VIEWS[0]!.id); + expect(resolveColumnView(SOQL_VIEWS, undefined)).toBe(SOQL_VIEWS[0]!.id); + }); +}); + +describe('view sets', () => { + it('each view set has a General view and unique ids', () => { + for (const views of [CALL_TREE_VIEWS, SOQL_VIEWS, DML_VIEWS]) { + expect(getColumnView(views, 'General')).toBeDefined(); + const ids = views.map((v) => v.id); + expect(new Set(ids).size).toBe(ids.length); + } + }); + + it('General is an explicit curated list so Self columns stay hidden', () => { + const general = getColumnView(CALL_TREE_VIEWS, 'General')!.fields!; + expect(general).not.toBeNull(); + // Totals are shown; Self variants are not part of any default view. + expect(general).toContain('dmlCount.total'); + expect(general).toContain('soqlCount.total'); + expect(general.some((f) => f.endsWith('.self') && f !== 'duration.self')).toBe(false); + }); + + it('General shows Heap but omits the near-always-zero SOSL columns', () => { + const general = getColumnView(CALL_TREE_VIEWS, 'General')!.fields!; + expect(general).toContain('heapAllocated.total'); + expect(general).not.toContain('soslCount.total'); + expect(general).not.toContain('soslRowCount.total'); + }); + + it('Database and Governor Limits merge DML, SOQL and SOSL count + row totals', () => { + const soslTotals = ['soslCount.total', 'soslRowCount.total']; + for (const viewId of ['Database', 'Governor Limits']) { + const view = getColumnView(CALL_TREE_VIEWS, viewId)!.fields!; + for (const f of [ + 'dmlCount.total', + 'soqlCount.total', + 'dmlRowCount.total', + 'soqlRowCount.total', + ...soslTotals, + ]) { + expect(view).toContain(f); + } + } + }); + + it('Memory view shows heap self and total', () => { + const memory = getColumnView(CALL_TREE_VIEWS, 'Memory')!.fields!; + expect(memory).toContain('heapAllocated.self'); + expect(memory).toContain('heapAllocated.total'); + }); + + it('SOQL Query Plan view exposes the explain-plan columns', () => { + const plan = getColumnView(SOQL_VIEWS, 'Query Plan')!.fields!; + expect(plan).toEqual( + expect.arrayContaining([ + 'relativeCost', + 'leadingOperationType', + 'sObjectType', + 'cardinality', + ]), + ); + }); + + it('SOQL views are General/Performance/Query Plan (no redundant Rows & Time)', () => { + expect(SOQL_VIEWS.map((v) => v.id)).toEqual(['General', 'Performance', 'Query Plan']); + }); + + it('getTableFields returns every column field in order', () => { + const { table } = fakeTable(['text', 'namespace', 'governorCost']); + expect(getTableFields(table)).toEqual(['text', 'namespace', 'governorCost']); + }); +}); diff --git a/log-viewer/src/tabulator/module/CommonModules.ts b/log-viewer/src/tabulator/module/CommonModules.ts index 974c1003..b935d244 100644 --- a/log-viewer/src/tabulator/module/CommonModules.ts +++ b/log-viewer/src/tabulator/module/CommonModules.ts @@ -8,6 +8,7 @@ export { ExportModule, FilterModule, FormatModule, + FrozenColumnsModule, GroupRowsModule, InteractionModule, KeybindingsModule, diff --git a/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts b/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts index 4b6a0f5e..13df25ee 100644 --- a/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts +++ b/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts @@ -75,6 +75,10 @@ interface RendererBase { // overwrites it with the string 'virtual' (stock bug workaround). renderMode?: string; }; + // Total width of all visible columns; used to size the table box so + // horizontally-overflowing rows span the full width (frozen sticky cells + // need a full-width containing block). + columnManager: { getWidth: () => number }; options: RendererOptions; }; elementVertical: HTMLElement; @@ -228,6 +232,10 @@ export class VirtualVerticalRenderer extends Renderer { // Pending rIC handle for the pre-warmer; cancelled on destroy/clearRows. private idlePrewarmHandle: number | null = null; + // Parked with the sticky Name column β€” re-add alongside _syncTableWidth(). + // Dedupes per-frame tableElement.minWidth writes. + // private lastTableMinWidth = -1; + // Last scrollLeft piped to scrollHorizontal (which writes DOM + dispatches // unconditionally β€” pipe only on change). NaN β†’ first frame always pipes. private lastPipedScrollLeft = NaN; @@ -369,6 +377,22 @@ export class VirtualVerticalRenderer extends Renderer { this._self().table.rowManager.scrollHorizontal(left); } + /** + * Sizes the table box to full column width so the sticky frozen column's + * containing block spans all columns (virtual rows are only viewport-wide). + * + * Parked with the sticky Name column: the full-width box fights the vertical + * renderer. Re-add with `lastTableMinWidth` (above) and the two call sites. + */ + // private _syncTableWidth(): void { + // const self = this._self(); + // const width = self.table.columnManager.getWidth(); + // if (width !== this.lastTableMinWidth) { + // this.lastTableMinWidth = width; + // self.tableElement.style.minWidth = `${width}px`; + // } + // } + /** RowManager lifecycle: holder scroll event (reads live scrollTop). */ scrollRows(_top: number, _dir: boolean): void { const holder = this._self().elementVertical; @@ -786,6 +810,7 @@ export class VirtualVerticalRenderer extends Renderer { this._detachAllRendered(); self.tableElement.style.paddingTop = '0'; self.tableElement.style.paddingBottom = '0'; + // this._syncTableWidth(); // disabled with the sticky Name column (see below) // Stock dispatches after every fill, including empty ones β€” GroupRows // relies on it to set minWidth when no data rows are visible. self.dispatch('render-virtual-fill'); @@ -887,6 +912,7 @@ export class VirtualVerticalRenderer extends Renderer { newBottom === lastIdx ? 0 : Math.max(0, this._totalHeight() - this._cumHeight(newBottom + 1)); self.tableElement.style.paddingTop = `${paddingTop}px`; self.tableElement.style.paddingBottom = `${paddingBottom}px`; + // this._syncTableWidth(); // disabled with the sticky Name column (see below) this.vDomTop = newTop; this.vDomBottom = newBottom; diff --git a/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRenderer.test.ts b/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRenderer.test.ts index dc207889..810214bb 100644 --- a/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRenderer.test.ts +++ b/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRenderer.test.ts @@ -16,7 +16,7 @@ function makeMockTable(): unknown { getDisplayRows: () => [], scrollHorizontal: () => {}, }, - columnManager: { element: {} }, + columnManager: { element: {}, getWidth: () => 200 }, options: {}, eventBus: { _events: {}, dispatch: () => {} }, }; @@ -356,7 +356,7 @@ function makeRendererWithRows(rows: RowStub[]): RendererForSetAnchor { getDisplayRows: () => rows, scrollHorizontal: () => {}, }, - columnManager: { element: {} }, + columnManager: { element: {}, getWidth: () => 200 }, options: {}, eventBus: { _events: {}, dispatch: () => {} }, }; @@ -550,7 +550,7 @@ function makeRerenderRenderer(initialRows: RowStub[]): { scrollHorizontal: () => {}, tableEmpty, }, - columnManager: { element: {} }, + columnManager: { element: {}, getWidth: () => 200 }, options: {}, eventBus: { _events: {}, dispatch: () => {} }, }; @@ -759,7 +759,7 @@ describe('VirtualVerticalRenderer.initialize stock-bug workaround', () => { }; const table = { rowManager, - columnManager: { element: {} }, + columnManager: { element: {}, getWidth: () => 200 }, options: {}, eventBus: { _events: {}, dispatch: () => {} }, }; diff --git a/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRendererAttach.test.ts b/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRendererAttach.test.ts index 6eb32dfc..4a35421c 100644 --- a/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRendererAttach.test.ts +++ b/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRendererAttach.test.ts @@ -68,7 +68,7 @@ function makeAttachSetup(rowCount: number): { getDisplayRows: () => rows, scrollHorizontal: () => {}, }, - columnManager: { element: {} }, + columnManager: { element: {}, getWidth: () => 200 }, options: {}, eventBus: { _events: {}, dispatch: () => {} }, }; diff --git a/log-viewer/src/tabulator/style/DataGrid.scss b/log-viewer/src/tabulator/style/DataGrid.scss index a432ce71..78f99423 100644 --- a/log-viewer/src/tabulator/style/DataGrid.scss +++ b/log-viewer/src/tabulator/style/DataGrid.scss @@ -48,6 +48,8 @@ $codicon-chevron-down: '\eab4'; sortArrowInactive: #bbb, // rows rowBackgroundColor: var(--vscode-editor-background), + // Must stay a Sass colour literal β€” tabulator.scss runs color.adjust() on it + // for calc rows (so it can't be a CSS var). rowAltBackgroundColor: transparent, rowBorderColor: transparent, rowTextColor: var(--vscode-editor-foreground), @@ -69,14 +71,27 @@ $codicon-chevron-down: '\eab4'; .tabulator { .tabulator-tableholder { - overflow-x: hidden; + overflow-x: auto; overflow-anchor: none; .tabulator-table { display: block; - background-color: default; } } + // Parked with the sticky Name column (see the commented `frozen: true` in the + // call-tree tables). Frozen cells use `background-color: inherit`, so they are only + // opaque where the row/section is opaque; even rows, calc/total rows and the header + // are transparent and would let scrolling columns show through. Re-add with the + // frozen column. (Selected/hovered rows are excluded so the Name cell keeps its + // inherited state colour; the last selector covers the sortable-header :hover state.) + // .tabulator-row.tabulator-row-even:not(.tabulator-selected):not(:hover) + // .tabulator-cell.tabulator-frozen, + // .tabulator-row.tabulator-calcs .tabulator-cell.tabulator-frozen, + // .tabulator-header .tabulator-col.tabulator-frozen, + // .tabulator-header .tabulator-col.tabulator-frozen.tabulator-col-sorter-element:hover { + // background-color: var(--vscode-editor-background); + // } + .tabulator-header-filter { input[type='search'] { color: var(--vscode-editor-foreground); @@ -96,11 +111,6 @@ $codicon-chevron-down: '\eab4'; } .tabulator-row { - background-color: default; - .tabulator-row-even { - background-color: default; - } - &.tabulator-group { font-family: monospace; background: unset;