From 84c817638a7255175b2ae60b866b51bf9ba8c4d9 Mon Sep 17 00:00:00 2001
From: Luke Cotter <4013877+lukecotter@users.noreply.github.com>
Date: Mon, 27 Jul 2026 14:57:58 +0100
Subject: [PATCH 1/6] feat(datagrid): move table filters into a shared filter
bar with search
Facet, range and search filters move out of column headers into a responsive
filter bar shared by Call Tree, Analysis and Database.
- add datagrid-facet-filter / datagrid-range-filter native-popover controls and
wire them plus a global search box through datagrid-filter-bar, replacing the
per-column list/MinMax header editors (editors/MinMax removed)
- OverflowList: collapse overflowing filters into a filter-icon popover panel
with slot-aware inline controls, re-measure on child resize, and on-screen flip
- consolidate Database (DML/SOQL/SOSL) tables onto the Call Tree column/sort
styling and shared filter-control/popover tokens in global.styles
- fix usage bars (Row Count, Time Taken): fill against the grid's own column
total, not a governor limit, so they show for small values and on group rows
---
CHANGELOG.md | 8 +
log-viewer/src/components/ContextMenu.ts | 163 +++++-----
log-viewer/src/components/OverflowList.ts | 65 +++-
log-viewer/src/components/VsSelect.ts | 202 +++++++++++-
.../src/components/datagrid-facet-filter.ts | 285 +++++++++++++++++
.../src/components/datagrid-filter-bar.ts | 31 +-
.../src/components/datagrid-range-filter.ts | 288 ++++++++++++++++++
.../analysis/components/AnalysisView.ts | 30 +-
.../call-tree/components/AggregatedTable.ts | 42 +--
.../call-tree/components/BottomUpTable.ts | 16 -
.../call-tree/components/CalltreeView.ts | 171 ++++++++---
.../call-tree/components/TableShared.ts | 8 -
.../call-tree/components/TimeOrderTable.ts | 44 +--
.../features/database/components/DMLView.ts | 158 +++++++---
.../features/database/components/SOQLView.ts | 159 +++++++---
.../features/database/components/SOSLView.ts | 133 +++++---
log-viewer/src/styles/global.styles.ts | 149 +++++++++
log-viewer/src/tabulator/ColumnViews.ts | 9 +-
log-viewer/src/tabulator/editors/MinMax.css | 27 --
log-viewer/src/tabulator/editors/MinMax.ts | 71 -----
log-viewer/src/tabulator/filters/MinMax.ts | 75 +----
.../filters/__tests__/MinMax.test.ts | 68 +----
log-viewer/src/tabulator/format/Progress.css | 4 +-
.../src/tabulator/format/ProgressComponent.ts | 12 +-
log-viewer/src/tabulator/groups/GroupSort.ts | 8 +-
log-viewer/src/tabulator/style/DataGrid.scss | 23 +-
26 files changed, 1639 insertions(+), 610 deletions(-)
create mode 100644 log-viewer/src/components/datagrid-facet-filter.ts
create mode 100644 log-viewer/src/components/datagrid-range-filter.ts
delete mode 100644 log-viewer/src/tabulator/editors/MinMax.css
delete mode 100644 log-viewer/src/tabulator/editors/MinMax.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 384b1878..85be8d89 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- ποΈ **Column views** (Call Tree, Analysis, Database): switch preset column sets, show/hide columns from the **Columns** button or the header right-click menu, inline **reset** to restore defaults; choices persist per view.
- π·οΈ **New columns**: **Object** (queried/target SObject, with group-by) on SOQL/DML; **SOSL Count/Rows**, **Avg Self Time** and optional **Self** variants for every governor metric; and a SOQL **Query Plan** view (Relative Cost, Leading Operation, SObject Type, Cardinality).
- π΄ **Timeline exception markers**: exceptions show as red lines, with a **Throws** count in method tooltips. ([#828])
+- π **Global search** (Call Tree, Analysis, Database): a free-text search box in every table's filter bar, matching across the whole tree/list.
+- π§° **Filter bar facets and ranges** (Call Tree, Database): Namespace, Object and Caller Namespace filters β and the Row Count / Time Taken ranges β moved out of column headers into compact chip/dropdown controls in the shared filter bar, alongside search.
### Changed
@@ -30,6 +32,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- ποΈ **Call Tree + Database styling**: VS Code style tree icons, and rows indent under their group headings. ([#832]).
- ποΈ **Modernised dropdowns**: searchable, compact controls that carry the field and value in one place (e.g. `Group: Namespace`, `Type: All`) ([#848]).
- β»οΈ Replaced the deprecated `webview-ui-toolkit` with [vscode-elements](https://github.com/vscode-elements/elements) for all UI controls. ([#576]).
+- ποΈ **Database table columns** (DML, SOQL, SOSL): consolidated onto the shared Call Tree column/sort styling for a consistent look across all tables.
+- π§± **Data grids**: a crisper header/content separator and tidied grid styling across all tables.
+
+### Fixed
+
+- π **Database usage bars** (Row Count, Time Taken): the usage bar was hidden whenever the rounded percentage was 0% (the common case for small row counts against large governor limits), so it rarely appeared; it now fills relative to the grid's own column total rather than a governor limit, shows on grouped summary rows, and Time Taken (ms) now shows a bar too.
## [1.20.1] 2026-07-23
diff --git a/log-viewer/src/components/ContextMenu.ts b/log-viewer/src/components/ContextMenu.ts
index a465bab5..c60c13ff 100644
--- a/log-viewer/src/components/ContextMenu.ts
+++ b/log-viewer/src/components/ContextMenu.ts
@@ -31,6 +31,8 @@ import '#vscode-elements/vscode-icon.js';
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
+import { globalStyles } from '../styles/global.styles.js';
+
export interface ContextMenuItem {
/** Unique identifier for the menu item */
id: string;
@@ -44,6 +46,12 @@ export interface ContextMenuItem {
disabled?: boolean;
/** If true, selecting the item (or its action) leaves the menu open (multi-toggle). */
keepOpen?: boolean;
+ /**
+ * If set, renders a real `.vs-checkbox` in place of the label's checkmark
+ * glyph β for multiselect rows (e.g. per-column visibility toggles). Leave
+ * unset for single-select rows (view presets), which keep the checkmark.
+ */
+ checked?: 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.
@@ -59,76 +67,77 @@ export interface ContextMenuItem {
*/
@customElement('context-menu')
export class ContextMenu extends LitElement {
- static styles = css`
- :host {
- position: fixed;
- z-index: 10000;
- display: none;
- }
-
- :host([visible]) {
- display: block;
- }
-
- .menu {
- min-width: 180px;
- padding: 6px 0;
- background-color: var(--vscode-menu-background, #252526);
- border: 1px solid var(--vscode-menu-border, #454545);
- border-radius: 6px;
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
- font-family: var(--vscode-font-family, system-ui, -apple-system, sans-serif);
- font-size: 13px;
- color: var(--vscode-menu-foreground, #cccccc);
- outline: none;
- }
-
- .menu-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 6px 20px 6px 12px;
- cursor: pointer;
- user-select: none;
- border-radius: 4px;
- margin: 0 6px;
- }
-
- .menu-item:hover:not(.disabled) {
- background-color: var(--vscode-menu-selectionBackground, #094771);
- color: var(--vscode-menu-selectionForeground, #ffffff);
- }
-
- .menu-item.disabled {
- color: var(--vscode-disabledForeground, #6e6e6e);
- cursor: default;
- }
-
- .label {
- flex: 1;
- }
-
- .shortcut {
- margin-left: 32px;
- opacity: 0.7;
- font-size: 12px;
- }
-
- .item-action {
- margin-left: 12px;
- opacity: 0.7;
- }
-
- .item-action:hover {
- opacity: 1;
- }
-
- .separator {
- height: 1px;
- margin: 6px 12px;
- background-color: var(--vscode-menu-separatorBackground, #454545);
- }
- `;
+ static styles = [
+ globalStyles,
+ css`
+ :host {
+ position: fixed;
+ z-index: 10000;
+ display: none;
+ }
+
+ :host([visible]) {
+ display: block;
+ }
+
+ .menu {
+ min-width: 180px;
+ padding: 6px 0;
+ font-size: var(--filter-popover-row-font-size);
+ outline: none;
+ }
+
+ .menu-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 6px 20px 6px 12px;
+ cursor: pointer;
+ user-select: none;
+ border-radius: 4px;
+ margin: 0 6px;
+ }
+
+ .menu-item:hover:not(.disabled) {
+ background-color: var(--vscode-list-hoverBackground);
+ }
+
+ .menu-item.disabled {
+ color: var(--vscode-disabledForeground, #6e6e6e);
+ cursor: default;
+ }
+
+ .label {
+ flex: 1;
+ }
+
+ .shortcut {
+ margin-left: 32px;
+ opacity: 0.7;
+ font-size: 12px;
+ }
+
+ .item-action {
+ margin-left: 12px;
+ opacity: 0.7;
+ }
+
+ .item-action:hover {
+ opacity: 1;
+ }
+
+ /* Purely visual β the row's own click handles the toggle. */
+ .menu-item .vs-checkbox {
+ pointer-events: none;
+ }
+
+ .separator {
+ height: 1px;
+ margin: 6px 12px;
+ background-color: var(--vscode-menu-separatorBackground, #454545);
+ }
+ `,
+ ];
@property({ type: Array }) items: ContextMenuItem[] = [];
@property({ type: Number }) x = 0;
@@ -275,7 +284,9 @@ export class ContextMenu extends LitElement {
}
return html`
-
+
`;
}
@@ -291,6 +302,16 @@ export class ContextMenu extends LitElement {
data-id="${item.id}"
@click="${() => this.handleItemClick(item)}"
>
+ ${
+ item.checked !== undefined
+ ? html``
+ : nothing
+ }
${item.label}
${item.shortcut ? html`${item.shortcut}` : nothing}
${
diff --git a/log-viewer/src/components/OverflowList.ts b/log-viewer/src/components/OverflowList.ts
index ce1079b0..52b0c2a3 100644
--- a/log-viewer/src/components/OverflowList.ts
+++ b/log-viewer/src/components/OverflowList.ts
@@ -48,11 +48,17 @@ export class OverflowList extends LitElement {
@property({ attribute: 'min-visible', type: Number })
minVisible = 0;
+ /** Codicon shown on the toggle. Default chevron (rotates on open); e.g. `filter`
+ * for a filters overflow β a non-chevron icon does not rotate. */
+ @property({ attribute: 'icon' })
+ icon = 'chevron-down';
+
/** How many items fit inline; the rest are moved into the popover menu. */
@state()
private visibleCount = Number.POSITIVE_INFINITY;
private resizeObserver?: ResizeObserver;
+ private childResizeObserver?: ResizeObserver;
private mutationObserver?: MutationObserver;
private lastWidth = -1;
/** Per-item widths (px), measured once with all items inline; drives sync overflow. */
@@ -129,8 +135,9 @@ export class OverflowList extends LitElement {
transition: transform 120ms ease;
}
- /* Native popover drives open/close; :has() flips the chevron while it's shown. */
- .container:has([popover]:popover-open) .overflow__chevron {
+ /* Native popover drives open/close; :has() flips the chevron while it's shown.
+ Only the default chevron rotates β a filter/other icon stays put. */
+ .container:has([popover]:popover-open) .overflow__chevron--rotate {
transform: rotate(180deg);
}
@@ -145,6 +152,12 @@ export class OverflowList extends LitElement {
.panel {
position: fixed;
position-anchor: --overflow-list-toggle;
+ /* Flip above / to the other side when the default placement would run
+ off-screen, so the whole menu stays visible near an edge. */
+ position-try-fallbacks:
+ flip-block,
+ flip-inline,
+ flip-block flip-inline;
inset: auto;
margin: 6px 0 0 0;
min-width: 200px;
@@ -204,20 +217,51 @@ export class OverflowList extends LitElement {
}
});
this.resizeObserver.observe(this);
+ // A child can change its own width without any host resize or childList change
+ // (e.g. a filter pill's label β "label: minβmax" summary, inside its shadow DOM).
+ // Re-measure when an INLINE child's width diverges from the cached value. Items in
+ // the overflow slot are skipped so slot reassignment can't feed back into a loop.
+ this.childResizeObserver = new ResizeObserver(() => {
+ if (!this.itemWidths) {
+ return;
+ }
+ const children = [...this.children] as HTMLElement[];
+ const stale = children.some(
+ (el, i) =>
+ el.getAttribute('slot') !== 'overflow' &&
+ this.itemWidths !== null &&
+ el.offsetWidth !== this.itemWidths[i],
+ );
+ if (stale) {
+ this._resetMeasurement();
+ this.requestUpdate();
+ }
+ });
// Items are light-DOM children, not a reactive property, so watch for set changes here.
this.mutationObserver = new MutationObserver(() => {
+ this._observeChildren();
this._resetMeasurement();
this.requestUpdate();
});
this.mutationObserver.observe(this, { childList: true });
+ this._observeChildren();
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.resizeObserver?.disconnect();
+ this.childResizeObserver?.disconnect();
this.mutationObserver?.disconnect();
}
+ /** (Re)point the per-child ResizeObserver at the current child set. */
+ private _observeChildren(): void {
+ this.childResizeObserver?.disconnect();
+ for (const child of this.children) {
+ this.childResizeObserver?.observe(child);
+ }
+ }
+
/** Drop cached widths and show every item, so the next render can re-measure them. */
private _resetMeasurement(): void {
this.itemWidths = null;
@@ -250,10 +294,17 @@ export class OverflowList extends LitElement {
for (const child of children) {
child.removeAttribute('slot');
}
+ // Hidden (e.g. mounted in an inactive tab panel): every offsetWidth is 0,
+ // which would cache as "everything fits" and never collapse. Defer without
+ // caching β the ResizeObserver fires on reveal (0 β width) and re-measures.
+ const avail = items.clientWidth;
+ if (avail === 0) {
+ return;
+ }
// Custom elements upgrade synchronously (defined before use), so offsetWidth is accurate.
this.itemWidths = children.map((el) => el.offsetWidth);
- this.lastWidth = items.clientWidth;
- this._recompute(this.lastWidth);
+ this.lastWidth = avail;
+ this._recompute(avail);
}
/** Set `visibleCount` from cached widths + `collapse-from`/`min-visible`, then re-slot. */
@@ -299,7 +350,11 @@ export class OverflowList extends LitElement {
title="Show ${hiddenCount} more"
>
+${hiddenCount}
-
+
`;
// Toggle pins to the collapse end, with the divider always adjacent to the items.
const toggle =
diff --git a/log-viewer/src/components/VsSelect.ts b/log-viewer/src/components/VsSelect.ts
index 18061d1e..1d7edab5 100644
--- a/log-viewer/src/components/VsSelect.ts
+++ b/log-viewer/src/components/VsSelect.ts
@@ -10,8 +10,26 @@ import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { repeat } from 'lit/directives/repeat.js';
+import { globalStyles } from '../styles/global.styles.js';
import { selectFaceText } from './selectFaceText.js';
+/** Current-value marker for single-select rows β same tick as vscode-checkbox/`.vs-checkbox`. */
+const checkIcon = html`
+
+`;
+
/** Chevron matching the vscode-elements select face (base `.icon` styles position it). */
const chevronDownIcon = html`
@@ -37,18 +55,103 @@ export const selectSizingStyles = css`
width: fit-content;
}
+ /* In the overflow panel: aligned label column + select control, matching the
+ facet/range panel rows (shared --filter-panel-label-width). */
+ :host([slot='overflow']) {
+ display: grid;
+ grid-template-columns: var(--filter-panel-label-width) 1fr;
+ align-items: center;
+ column-gap: 8px;
+ width: 100%;
+ }
+
+ .vs-panel-label {
+ grid-column: 1;
+ font-size: var(--filter-control-font-size);
+ color: var(--vscode-foreground);
+ white-space: nowrap;
+ }
+
+ .vs-panel-control {
+ grid-column: 2;
+ justify-self: start;
+ }
+
.dropdown {
- /* widens the popup past the control width the base sets as an inline
- style: used width = max(width, min-width) */
+ /* Hugs its widest option (base sets an inline width style; min-width here
+ floors that at the trigger's own width so the popup is never narrower
+ than the control that opens it β same content-sizing as the facet
+ popover, no fixed 220px). */
min-width: max-content;
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
+ padding: 4px;
+ background-color: var(--filter-popover-bg);
+ border-color: var(--filter-popover-border-color);
+ border-radius: var(--filter-popover-radius);
+ box-shadow: var(--filter-popover-shadow);
+ }
+
+ /* Vendor's '.option.single-select' (two classes, higher specificity than a
+ plain '.option' rule) sets display:block β it wins over a '.option {
+ display:flex }' override regardless of source order, which is why the
+ checkmark and label used to stack instead of sitting inline. Override
+ the two-class selector directly so this is the row layout for every
+ single-select option, matching the facet popover row exactly. */
+ .option,
+ .option.single-select {
+ display: flex;
+ align-items: center;
+ flex-wrap: nowrap;
+ gap: 6px;
+ box-sizing: border-box;
+ font-size: var(--filter-popover-row-font-size);
+ padding: var(--filter-popover-row-padding);
+ height: auto;
+ line-height: normal;
+ min-height: 0;
+ overflow: visible;
+ white-space: normal;
+ }
+
+ /* Row highlight is the same calm grey as every other filter-bar popover row
+ β the base vendor style uses the blue list-active-selection token here,
+ which is the one place blue leaked into the shared popover family.
+ Current value is shown by the checkmark (.option-check), not a fill. */
+ .option.active,
+ .option.active:hover {
+ background-color: var(--vscode-list-hoverBackground);
+ color: var(--vscode-foreground);
+ outline-color: var(--vscode-list-hoverBackground);
+ }
+
+ /* Same 16px marker column as the facet checklist's checkbox (.vs-checkbox), so
+ single-select and multi-select dropdowns share one label indent + tick. */
+ .option-check {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+ color: var(--vscode-foreground);
+ }
+
+ .option:not(.selected) .option-check {
+ visibility: hidden;
+ }
+
+ /* Base sets width:100%, which overflows the flex row now that a check icon
+ shares it with the label. */
+ .option-label {
+ flex: 1;
+ width: auto;
+ min-width: 0;
}
.face {
cursor: pointer;
}
- .face:hover {
+ :host(:not([dense])) .face:hover {
background-color: var(--vscode-toolbar-hoverBackground);
}
@@ -89,12 +192,40 @@ export const selectSizingStyles = css`
padding-left: 4px;
}
- /* Rows carrying a reset affordance lay the icon out at the trailing edge. */
- .option.resettable-option {
- display: flex;
- align-items: center;
+ /* Dense face box model (height/padding/font-size/border-radius) comes from
+ the shared .filter-control class (applied via classMap below) so it
+ matches the filter bar's facet/range trigger pills. vscode-single-select's
+ base styles still fix inner chrome to a 13px-font layout (.select-face
+ .text height:18px, .combobox-input line-height:16px, .combobox-button
+ height:16px) that .filter-control's own padding/font-size doesn't reach β
+ these resets on the inner chrome are what actually make the row match. */
+ :host([dense]) .select-face .text {
+ height: auto;
+ line-height: inherit;
+ }
+
+ :host([dense]) .select-face .icon {
+ top: 50%;
+ transform: translateY(-50%);
+ }
+
+ :host([dense]) .combobox-input {
+ height: auto;
+ padding: 0;
+ font-size: inherit;
+ line-height: inherit;
}
+ :host([dense]) .combobox-button {
+ height: auto;
+ align-self: stretch;
+ margin: 0;
+ padding: 0 3px;
+ box-sizing: border-box;
+ }
+
+ /* Rows carrying a reset affordance lay the icon out at the trailing edge
+ (.option is already flex, see above). */
.option-reset {
margin-left: auto;
}
@@ -103,7 +234,11 @@ export const selectSizingStyles = css`
/** vscode-single-select where the control fits the selected value and the popup its widest option. */
@customElement('vs-select')
export class VsSelect extends VscodeSingleSelect {
- static styles = [...VscodeSingleSelect.styles, selectSizingStyles];
+ static styles = [...VscodeSingleSelect.styles, globalStyles, selectSizingStyles];
+
+ /** Shrinks padding/font-size to match the filter bar's facet/range trigger pills. */
+ @property({ type: Boolean, reflect: true })
+ dense = false;
/** Field name shown before the value when active, e.g. `Group` β `Group: Namespace`. */
@property({ type: String })
@@ -120,6 +255,31 @@ export class VsSelect extends VscodeSingleSelect {
@property({ attribute: false })
resettableValues: string[] = [];
+ /**
+ * Marks this as a filter control (e.g. the Type dropdown) rather than a view
+ * preference (Columns, Group by): its trigger gets the same active-pill
+ * treatment as facet/range triggers whenever its value is a real filter β
+ * the host computes that (it knows what "no filter" means for this select,
+ * which usually differs from `emptyValue`/the bold-text `active` state).
+ */
+ @property({ type: Boolean, reflect: true })
+ filterActive = false;
+
+ /** Reactive mirror of the native `slot` attribute so the control re-renders as
+ * an aligned label + select row when `` moves it into its panel. */
+ @property({ attribute: 'slot' }) slot = '';
+
+ override render(): TemplateResult {
+ const base = super.render() as TemplateResult;
+ if (this.slot !== 'overflow') {
+ return base;
+ }
+ // In the overflow panel: a label column + the compact select, aligned with
+ // the facet/range rows via the shared label-width token.
+ return html`${this.label}
+ ${base}
`;
+ }
+
private _faceContent(): TemplateResult {
const { prefixText, valueText } = selectFaceText({
prefix: this.prefix,
@@ -143,10 +303,6 @@ export class VsSelect extends VscodeSingleSelect {
}
protected override _renderOptions(): TemplateResult | TemplateResult[] {
- if (this.resettableValues.length === 0) {
- return super._renderOptions();
- }
-
return html`
+ ${selected ? checkIcon : nothing}
${op.label}${edited ? ' β’' : ''}
${
edited
@@ -201,6 +358,7 @@ export class VsSelect extends VscodeSingleSelect {
`;
},
)}
+ ${this._renderPlaceholderOption(this._opts.numOfVisibleOptions < 1)}
`;
}
@@ -220,7 +378,13 @@ export class VsSelect extends VscodeSingleSelect {
aria-expanded=${this.open ? 'true' : 'false'}
aria-haspopup="listbox"
aria-label=${ifDefined(this.label)}
- class=${classMap({ 'select-face': true, face: true, active })}
+ class=${classMap({
+ 'select-face': true,
+ face: true,
+ active,
+ 'filter-control': this.dense,
+ 'filter-control--active': this.filterActive,
+ })}
@click=${this._onFaceClick}
role="combobox"
tabindex="0"
@@ -240,7 +404,15 @@ export class VsSelect extends VscodeSingleSelect {
emptyValue: this.emptyValue,
});
return html`
-
+
${prefixText ? html`
${prefixText}` : ''}
` moves it into (`overflow`) / out of its popover. */
+ @property({ attribute: 'slot' }) slot = '';
+
+ @state() private _selected = new Set
();
+
+ private _anchorName = `--facet-anchor-${nextId++}`;
+ private _popoverId = `facet-popover-${nextId}`;
+
+ static styles = [
+ globalStyles,
+ css`
+ :host {
+ display: inline-flex;
+ }
+
+ /* In the overflow panel the control is a full-width row: label + checklist. */
+ :host([slot='overflow']) {
+ display: block;
+ width: 100%;
+ }
+
+ /* Aligned row: label column (shared width) + control column. */
+ .facet-panel {
+ display: grid;
+ grid-template-columns: var(--filter-panel-label-width) 1fr;
+ column-gap: 8px;
+ row-gap: 4px;
+ align-items: start;
+ }
+
+ .facet-panel__label {
+ grid-column: 1;
+ font-size: var(--filter-control-font-size);
+ font-weight: 600;
+ color: var(--vscode-foreground);
+ /* Nudge onto the first option row's baseline. */
+ padding-top: 3px;
+ }
+
+ .facet-panel__options {
+ grid-column: 2;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ max-height: 180px;
+ overflow-y: auto;
+ }
+
+ .facet-panel .facet-popover__clear {
+ grid-column: 2;
+ width: auto;
+ padding: 2px 0;
+ }
+
+ .facet-trigger__count {
+ font-weight: 600;
+ color: var(--vscode-badge-foreground);
+ background-color: var(--vscode-badge-background);
+ border-radius: 999px;
+ padding: 0 5px;
+ font-size: 10px;
+ line-height: 1.5;
+ visibility: hidden;
+ }
+
+ .facet-trigger__count.visible {
+ visibility: visible;
+ }
+
+ .facet-trigger__chevron {
+ color: var(--vscode-descriptionForeground);
+ }
+
+ .facet-popover {
+ position: fixed;
+ position-area: bottom span-left;
+ /* Flip above / to the other side when the default placement would run
+ off-screen, so every value stays visible. */
+ position-try-fallbacks:
+ flip-block,
+ flip-inline,
+ flip-block flip-inline;
+ /* Auto-hide when the trigger scrolls out of view instead of stranding
+ at stale fixed coords in the top layer. */
+ position-visibility: anchors-visible;
+ inset: auto;
+ margin: 6px 0 0 0;
+ /* Hug the widest option row's content; never narrower than the
+ trigger button itself (anchor-size reads the anchored trigger's
+ box β same content-sizing contract as VsSelect's '.dropdown'). */
+ width: max-content;
+ min-width: anchor-size(width);
+ max-width: min(92vw, 280px);
+ max-height: min(60vh, 360px);
+ overflow-y: auto;
+ padding: 4px;
+ }
+
+ .facet-option {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .facet-option span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .facet-popover__empty {
+ padding: 6px 8px;
+ color: var(--vscode-descriptionForeground);
+ font-size: 12px;
+ }
+
+ .facet-popover__footer {
+ margin-top: 2px;
+ border-top: 1px solid var(--divider-background, var(--vscode-menu-separatorBackground));
+ }
+
+ .facet-popover__clear {
+ display: block;
+ width: 100%;
+ padding: 4px 8px;
+ border: none;
+ background: none;
+ color: var(--vscode-textLink-foreground);
+ font: inherit;
+ font-size: 12px;
+ text-align: left;
+ cursor: pointer;
+ visibility: hidden;
+ }
+
+ .facet-popover__clear.visible {
+ visibility: visible;
+ }
+
+ .facet-popover__clear:hover {
+ color: var(--vscode-textLink-activeForeground);
+ }
+ `,
+ ];
+
+ render() {
+ return this.slot === 'overflow' ? this._renderPanel() : this._renderInline();
+ }
+
+ /** Compact bar pill that opens the checklist in a native popover. */
+ private _renderInline() {
+ const count = this._selected.size;
+ return html`
+
+
+ ${this._options()}
+
+
+ `;
+ }
+
+ /** Full-width row (inside the overflow panel): label + inline checklist. */
+ private _renderPanel() {
+ const count = this._selected.size;
+ return html`
+
+
${this.label}
+
${this._options()}
+ ${
+ count > 0
+ ? html`
`
+ : ''
+ }
+
+ `;
+ }
+
+ private _options() {
+ return this.values.length === 0
+ ? html`No values
`
+ : this.values.map(
+ (value) => html`
+
+ `,
+ );
+ }
+
+ private _toggle(value: string): void {
+ const next = new Set(this._selected);
+ if (next.has(value)) {
+ next.delete(value);
+ } else {
+ next.add(value);
+ }
+ this._selected = next;
+ this._emit();
+ }
+
+ private _clear(): void {
+ this._selected = new Set();
+ this._emit();
+ }
+
+ private _emit(): void {
+ this.dispatchEvent(
+ new CustomEvent('datagrid-facet-change', {
+ detail: { selected: [...this._selected] },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'datagrid-facet-filter': DatagridFacetFilter;
+ }
+}
diff --git a/log-viewer/src/components/datagrid-filter-bar.ts b/log-viewer/src/components/datagrid-filter-bar.ts
index 9e7908d8..31327786 100644
--- a/log-viewer/src/components/datagrid-filter-bar.ts
+++ b/log-viewer/src/components/datagrid-filter-bar.ts
@@ -42,18 +42,41 @@ export class DatagridFilterBar extends LitElement {
display: flex;
align-items: flex-end;
gap: 8px;
+ /* Allow the clusters to shrink below content width so a section's
+ overflow-list receives a constrained width and can collapse. */
+ min-width: 0;
}
- /* Right cluster consumes the slack and right-aligns grouping + actions. */
- .filter-bar__right {
+ /* Left cluster consumes the slack (via its growable filters section) so a
+ collapsed overflow-list regains width and can re-expand on widen; the
+ right cluster then sits at the far edge with no growth. */
+ .filter-bar__left {
flex: 1 1 auto;
- justify-content: flex-end;
+ }
+
+ .filter-bar__right {
+ flex: 0 0 auto;
}
.section {
display: flex;
align-items: flex-end;
gap: 4px;
+ /* Shrinkable so the filters section can hand its overflow-list a
+ bounded width (min-width:auto would keep it at content size). */
+ min-width: 0;
+ }
+
+ /* Filters section takes the left cluster's slack and passes it to its
+ slotted overflow-list, so the list always knows the full width
+ available and can re-expand collapsed items when the bar widens. */
+ .section--grow {
+ flex: 1 1 auto;
+ }
+
+ .section--grow ::slotted([slot='filters']) {
+ flex: 1 1 auto;
+ min-width: 0;
}
.section[hidden] {
@@ -102,7 +125,7 @@ export class DatagridFilterBar extends LitElement {
${filtersDivider ? this._divider() : nothing}
-
diff --git a/log-viewer/src/components/datagrid-range-filter.ts b/log-viewer/src/components/datagrid-range-filter.ts
new file mode 100644
index 00000000..653d765c
--- /dev/null
+++ b/log-viewer/src/components/datagrid-range-filter.ts
@@ -0,0 +1,288 @@
+/*
+ * Copyright (c) 2026 Certinia Inc. All rights reserved.
+ */
+import '#vscode-elements/vscode-icon.js';
+
+import { LitElement, css, html } from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+
+import { globalStyles } from '../styles/global.styles.js';
+
+let nextId = 0;
+
+export type FilterRange = { start: number | null; end: number | null };
+
+/**
+ * Min/max range control for the datagrid filter bar's `filters` slot β a pill
+ * that opens a native-popover with two number inputs (replaces per-column
+ * `headerFilter: MinMaxEditor`). Purely presentational: on Enter/blur it fires
+ * `datagrid-range-change` with `{ start, end }` (each `null` when empty), and
+ * the host owns turning that into a filter predicate.
+ */
+@customElement('datagrid-range-filter')
+export class DatagridRangeFilter extends LitElement {
+ @property() label = '';
+ /** Optional unit suffix shown on the trigger, e.g. "ms". */
+ @property() unit = '';
+ /** Reactive mirror of the native `slot` attribute so the element re-renders
+ * when `
` moves it into (`overflow`) / out of its popover. */
+ @property({ attribute: 'slot' }) slot = '';
+
+ @state() private _start: string = '';
+ @state() private _end: string = '';
+
+ private _debounceHandle?: ReturnType;
+ private _anchorName = `--range-anchor-${nextId++}`;
+ private _popoverId = `range-popover-${nextId}`;
+
+ static styles = [
+ globalStyles,
+ css`
+ :host {
+ display: inline-flex;
+ }
+
+ /* In the overflow panel the control is a full-width row of inline inputs. */
+ :host([slot='overflow']) {
+ display: block;
+ width: 100%;
+ }
+
+ /* Aligned row: label column (shared width) + control column. */
+ .range-panel {
+ display: grid;
+ grid-template-columns: var(--filter-panel-label-width) 1fr;
+ align-items: center;
+ column-gap: 8px;
+ }
+
+ .range-panel__label {
+ grid-column: 1;
+ font-size: var(--filter-control-font-size);
+ color: var(--vscode-foreground);
+ white-space: nowrap;
+ }
+
+ .range-panel__control {
+ grid-column: 2;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .range-panel__unit {
+ font-size: 11px;
+ color: var(--vscode-descriptionForeground);
+ }
+
+ .range-trigger {
+ font-variant-numeric: tabular-nums;
+ }
+
+ .range-trigger__chevron {
+ color: var(--vscode-descriptionForeground);
+ }
+
+ .range-popover {
+ position: fixed;
+ position-area: bottom span-left;
+ /* Flip above / to the other side when the default placement would run
+ off-screen so the inputs stay visible. */
+ position-try-fallbacks:
+ flip-block,
+ flip-inline,
+ flip-block flip-inline;
+ /* Auto-hide when the trigger scrolls out of view instead of stranding
+ at stale fixed coords in the top layer. */
+ position-visibility: anchors-visible;
+ inset: auto;
+ margin: 6px 0 0 0;
+ padding: 8px;
+ }
+
+ /* Flex layout only while open β a class-level display would beat the UA
+ [popover]:not(:popover-open){display:none} rule and keep the closed
+ popover laid out (visible behind other content). */
+ .range-popover:popover-open {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .range-popover__inputs {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .range-popover__input {
+ width: 8ch;
+ box-sizing: border-box;
+ padding: 3px 6px;
+ font: inherit;
+ font-size: 12px;
+ color: var(--vscode-settings-numberInputForeground);
+ background-color: var(--vscode-settings-numberInputBackground);
+ border: 1px solid var(--vscode-settings-numberInputBorder, transparent);
+ border-radius: 4px;
+ appearance: textfield;
+ }
+
+ .range-popover__input::-webkit-outer-spin-button,
+ .range-popover__input::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+ }
+
+ .range-popover__input:focus {
+ outline: 1px solid var(--vscode-focusBorder);
+ outline-offset: -1px;
+ }
+
+ .range-popover__clear {
+ align-self: flex-start;
+ padding: 2px 0;
+ border: none;
+ background: none;
+ color: var(--vscode-textLink-foreground);
+ font: inherit;
+ font-size: 12px;
+ cursor: pointer;
+ }
+
+ .range-popover__clear:hover {
+ color: var(--vscode-textLink-activeForeground);
+ }
+ `,
+ ];
+
+ render() {
+ return this.slot === 'overflow' ? this._renderPanel() : this._renderInline();
+ }
+
+ /** Compact bar pill that opens the min/max inputs in a native popover. */
+ private _renderInline() {
+ const active = this._start !== '' || this._end !== '';
+ const summary = active
+ ? `${this.label}: ${this._start || 'β¦'}β${this._end || 'β¦'}${this.unit}`
+ : this.label;
+
+ return html`
+
+
+ ${this._inputs()}
+ ${
+ active
+ ? html``
+ : ''
+ }
+
+ `;
+ }
+
+ /** Full-width row (inside the overflow panel): label + inline min/max inputs. */
+ private _renderPanel() {
+ const active = this._start !== '' || this._end !== '';
+ return html`
+
+
${this.label}
+
+ ${this._inputs()}
+ ${this.unit ? html`${this.unit}` : ''}
+ ${
+ active
+ ? html``
+ : ''
+ }
+
+
+ `;
+ }
+
+ private _inputs() {
+ return html`
+
+
+
`;
+ }
+
+ private _onInput(event: Event): void {
+ const inputs = this.renderRoot.querySelectorAll('.range-popover__input');
+ this._start = inputs[0]?.value ?? '';
+ this._end = inputs[1]?.value ?? '';
+ event.stopPropagation();
+ clearTimeout(this._debounceHandle);
+ this._debounceHandle = setTimeout(() => this._emit(), 150);
+ }
+
+ private _onKeydown(event: KeyboardEvent): void {
+ if (event.key === 'Enter') {
+ clearTimeout(this._debounceHandle);
+ this._emit();
+ (event.target as HTMLInputElement).blur();
+ }
+ }
+
+ private _clear(): void {
+ clearTimeout(this._debounceHandle);
+ this._start = '';
+ this._end = '';
+ this._emit();
+ }
+
+ private _emit(): void {
+ this.dispatchEvent(
+ new CustomEvent<{ range: FilterRange }>('datagrid-range-change', {
+ detail: {
+ range: {
+ start: this._start !== '' ? +this._start : null,
+ end: this._end !== '' ? +this._end : null,
+ },
+ },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'datagrid-range-filter': DatagridRangeFilter;
+ }
+}
diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts
index 18b1f108..b0ea0529 100644
--- a/log-viewer/src/features/analysis/components/AnalysisView.ts
+++ b/log-viewer/src/features/analysis/components/AnalysisView.ts
@@ -2,7 +2,6 @@
* Copyright (c) 2022 Certinia Inc. All rights reserved.
*/
import '#vscode-elements/vscode-button.js';
-import '#vscode-elements/vscode-checkbox.js';
import '#vscode-elements/vscode-option.js';
import '../../../components/VsSelect.js';
import '#vscode-elements/vscode-toolbar-button.js';
@@ -41,8 +40,8 @@ import { globalStyles } from '../../../styles/global.styles.js';
import { soqlSyntaxStyles } from '../../soql/styles/soql-syntax.css.js';
// Components
-import '../../../components/GridSkeleton.js';
import '../../../components/datagrid-filter-bar.js';
+import '../../../components/GridSkeleton.js';
/** The Name column is always shown in the analysis table. */
const ALWAYS_VISIBLE = ['text'];
@@ -92,6 +91,15 @@ export class AnalysisView extends LitElement {
gap: 4px;
align-items: flex-end;
}
+
+ .filter-container vscode-button {
+ height: var(--filter-control-height);
+ }
+
+ .filter-container vscode-button::part(base) {
+ padding: var(--filter-control-padding);
+ font-size: var(--filter-control-font-size);
+ }
`,
categoryColoringStyles,
];
@@ -188,6 +196,7 @@ export class AnalysisView extends LitElement {
>
- Details
+
true,
showDetailsFilter: this._showDetailsFilter,
onFilterCacheClear: () => {
if (!this.blockClearHighlights && this.totalMatches > 0) {
diff --git a/log-viewer/src/features/call-tree/components/AggregatedTable.ts b/log-viewer/src/features/call-tree/components/AggregatedTable.ts
index 0f371930..13ee669d 100644
--- a/log-viewer/src/features/call-tree/components/AggregatedTable.ts
+++ b/log-viewer/src/features/call-tree/components/AggregatedTable.ts
@@ -6,8 +6,6 @@ import { Tabulator } from 'tabulator-tables';
import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js';
import { formatDuration } from '../../../core/utility/Util.js';
-import MinMaxEditor from '../../../tabulator/editors/MinMax.js';
-import { minMaxTreeFilter } from '../../../tabulator/filters/MinMax.js';
import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js';
import { VirtualVerticalRenderer } from '../../../tabulator/renderer/VirtualVerticalRenderer.js';
import { toAggregatedCallTree, type AggregatedRow } from '../utils/Aggregation.js';
@@ -32,10 +30,6 @@ export function createAggregatedTable(
): { table: Tabulator; tableBuilt: Promise } {
registerTableModules();
- const namespaceFilterCache = new Map();
- const totalTimeFilterCache = new Map();
- const selfTimeFilterCache = new Map();
-
let childIndent: number | undefined;
const tableRef: { current: Tabulator | undefined } = { current: undefined };
@@ -124,15 +118,6 @@ export function createAggregatedTable(
sorter: 'string',
width: 100,
minWidth: 80,
- headerFilter: 'list',
- headerFilterFunc: callbacks.namespaceFilter,
- headerFilterFuncParams: { filterCache: namespaceFilterCache },
- headerFilterParams: {
- values: rootMethod.namespaces,
- clearable: true,
- multiselect: true,
- },
- headerFilterLiveFilter: false,
},
{
title: 'Caller Namespace',
@@ -171,10 +156,6 @@ export function createAggregatedTable(
bottomCalcFormatter: progressFormatterMS,
bottomCalc: 'sum',
bottomCalcFormatterParams: { precision: 2, totalValue: rootMethod.duration.total },
- headerFilter: MinMaxEditor,
- headerFilterFunc: minMaxTreeFilter,
- headerFilterFuncParams: { columnName: 'totalTime', filterCache: totalTimeFilterCache },
- headerFilterLiveFilter: false,
tooltip: (_event, cell) => formatDuration(cell.getValue()),
},
{
@@ -194,10 +175,6 @@ export function createAggregatedTable(
bottomCalcFormatter: progressFormatterMS,
bottomCalc: selfTimeBottomCalc,
bottomCalcFormatterParams: { precision: 2, totalValue: rootMethod.duration.total },
- headerFilter: MinMaxEditor,
- headerFilterFunc: minMaxTreeFilter,
- headerFilterFuncParams: { columnName: 'totalSelfTime', filterCache: selfTimeFilterCache },
- headerFilterLiveFilter: false,
tooltip: (_event, cell) => formatDuration(cell.getValue()),
},
{
@@ -217,18 +194,15 @@ export function createAggregatedTable(
});
tableRef.current = table;
- // Filter caches are cleared once per render via `renderStarted`. Row ids
- // produced by `toAggregatedCallTree` are globally unique within a build
- // (per-build monotonic counter), so cached `deepFilter` results stay valid
- // across the cascaded `filter.filter()` passes Tabulator runs for each
- // expanded subtree β `getChildren` β `filter.filter(config.children)`
- // would otherwise fire `dataFiltered` multiple times per user action,
- // defeating the cache. If row ids ever lose their uniqueness guarantee
- // this must move back to `dataFiltered`.
+ // The host's filter caches (search/type/debug/namespace/duration) are cleared once per
+ // render via `renderStarted` β see CalltreeView's `onFilterCacheClear`. Row ids produced
+ // by `toAggregatedCallTree` are globally unique within a build (per-build monotonic
+ // counter), so cached `deepFilter` results stay valid across the cascaded
+ // `filter.filter()` passes Tabulator runs for each expanded subtree β
+ // `getChildren` β `filter.filter(config.children)` would otherwise fire `dataFiltered`
+ // multiple times per user action, defeating the cache. If row ids ever lose their
+ // uniqueness guarantee this must move back to `dataFiltered`.
table.on('renderStarted', () => {
- namespaceFilterCache.clear();
- totalTimeFilterCache.clear();
- selfTimeFilterCache.clear();
callbacks.onFilterCacheClear?.();
callbacks.onRenderStarted();
});
diff --git a/log-viewer/src/features/call-tree/components/BottomUpTable.ts b/log-viewer/src/features/call-tree/components/BottomUpTable.ts
index c69da3c3..5f8b6bca 100644
--- a/log-viewer/src/features/call-tree/components/BottomUpTable.ts
+++ b/log-viewer/src/features/call-tree/components/BottomUpTable.ts
@@ -6,8 +6,6 @@ import { Tabulator, type Options } from 'tabulator-tables';
import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js';
import { formatDuration } from '../../../core/utility/Util.js';
-import MinMaxEditor from '../../../tabulator/editors/MinMax.js';
-import MinMaxFilter from '../../../tabulator/filters/MinMax.js';
import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js';
import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js';
import { GroupChildIndent } from '../../../tabulator/groups/GroupChildIndent.js';
@@ -186,14 +184,6 @@ export function createBottomUpTable(
sorter: 'string',
width: 100,
minWidth: 80,
- headerFilter: 'list',
- headerFilterFunc: 'in',
- headerFilterParams: {
- values: rootMethod.namespaces,
- clearable: true,
- multiselect: true,
- },
- headerFilterLiveFilter: false,
},
{
title: 'Caller Namespace',
@@ -240,9 +230,6 @@ export function createBottomUpTable(
bottomCalcFormatter: progressFormatterMS,
bottomCalc: totalTimeBottomCalc,
bottomCalcFormatterParams: { precision: 2, totalValue: rootMethod.duration.total },
- headerFilter: MinMaxEditor,
- headerFilterFunc: MinMaxFilter,
- headerFilterLiveFilter: false,
tooltip: (_event, cell) => formatDuration(cell.getValue()),
},
{
@@ -262,9 +249,6 @@ export function createBottomUpTable(
bottomCalcFormatter: progressFormatterMS,
bottomCalc: 'sum',
bottomCalcFormatterParams: { precision: 2, totalValue: rootMethod.duration.total },
- headerFilter: MinMaxEditor,
- headerFilterFunc: MinMaxFilter,
- headerFilterLiveFilter: false,
tooltip: (_event, cell) => formatDuration(cell.getValue()),
},
{
diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts
index e09731a9..1bffa979 100644
--- a/log-viewer/src/features/call-tree/components/CalltreeView.ts
+++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts
@@ -2,7 +2,6 @@
* Copyright (c) 2022 Certinia Inc. All rights reserved.
*/
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';
@@ -27,6 +26,8 @@ import { deepFilter } from '../utils/DetailsFilter.js';
import { expandCollapseAll } from '../utils/ExpandCollapse.js';
import type { TimeOrderRow } from '../utils/TimeOrderTree.js';
+import { inMsRange, type FilterRange } from '../../../tabulator/filters/MinMax.js';
+
import dataGridStyles from '../../../tabulator/style/DataGrid.scss';
// styles
@@ -37,7 +38,10 @@ 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-facet-filter.js';
import '../../../components/datagrid-filter-bar.js';
+import '../../../components/datagrid-range-filter.js';
+import '../../../components/OverflowList.js';
// Table creation functions
import { createAggregatedTable } from './AggregatedTable.js';
@@ -91,14 +95,19 @@ export class CalltreeView extends LitElement {
};
bottomUpGroupBy = 'None';
typeFilter = 'All';
+ namespaceSelected: string[] = [];
+ totalTimeRange: FilterRange = { start: null, end: null };
+ selfTimeRange: FilterRange = { start: null, end: null };
debugOnlyFilterCache = new Map();
typeFilterCache = new Map();
+ namespaceFilterCache = new Map();
+ totalTimeFilterCache = new Map();
+ selfTimeFilterCache = new Map();
findMap: { [key: number]: RowComponent } = {};
totalMatches = 0;
blockClearHighlights = true;
- searchString = '';
findArgs: { text: string; count: number; options: { matchCase: boolean } } = {
text: '',
count: 0,
@@ -211,12 +220,15 @@ export class CalltreeView extends LitElement {
gap: 0;
}
- .view-mode-buttons vscode-button {
- height: 26px;
+ .view-mode-buttons vscode-button,
+ .filter-container vscode-button {
+ height: var(--filter-control-height);
}
- .view-mode-buttons vscode-button::part(base) {
- padding: 0 8px;
+ .view-mode-buttons vscode-button::part(base),
+ .filter-container vscode-button::part(base) {
+ padding: var(--filter-control-padding);
+ font-size: var(--filter-control-font-size);
}
.view-mode-buttons vscode-button:first-child {
@@ -291,6 +303,7 @@ export class CalltreeView extends LitElement {
>
-
- Details
+
+
${
isTimeOrder || this.viewMode === 'aggregated'
? html`
- Debug Only
-
All
@@ -346,12 +361,49 @@ export class CalltreeView extends LitElement {
`
: ''
}
-
+
+
+
+
+
+
+
+ ${
+ isTimeOrder || this.viewMode === 'aggregated'
+ ? html`
+
+ `
+ : ''
+ }
+
${
this.viewMode === 'bottom-up'
? html`
('#bottom-up-tree-table');
if (container) {
await this._renderBottomUpTree(container, this.rootMethod);
+ this._updateFiltering();
}
}
@@ -643,6 +696,26 @@ export class CalltreeView extends LitElement {
const target = event.target as HTMLInputElement;
this.typeFilter = target.value || 'All';
this.filterState.selectedTypes = new Set(target.value ? [target.value] : []);
+ // typeFilter is a plain field (not @state), so the Type select's
+ // `.filterActive` binding doesn't repaint until some other reactive
+ // update happens to coincide β force it so the active border shows on
+ // the very first pick, not a later render.
+ this.requestUpdate();
+ this._updateFiltering();
+ }
+
+ _handleNamespaceFacet(event: CustomEvent<{ selected: string[] }>) {
+ this.namespaceSelected = event.detail.selected;
+ this._updateFiltering();
+ }
+
+ _handleTotalTimeRange(event: CustomEvent<{ range: FilterRange }>) {
+ this.totalTimeRange = event.detail.range;
+ this._updateFiltering();
+ }
+
+ _handleSelfTimeRange(event: CustomEvent<{ range: FilterRange }>) {
+ this.selfTimeRange = event.detail.range;
this._updateFiltering();
}
@@ -654,9 +727,24 @@ export class CalltreeView extends LitElement {
this.debugOnlyFilterCache.clear();
this.typeFilterCache.clear();
+ this.namespaceFilterCache.clear();
+ this.totalTimeFilterCache.clear();
+ this.selfTimeFilterCache.clear();
const filtersToAdd = [];
+ if (this.namespaceSelected.length > 0) {
+ filtersToAdd.push(this._namespaceBarFilter);
+ }
+
+ if (this.totalTimeRange.start !== null || this.totalTimeRange.end !== null) {
+ filtersToAdd.push(this._totalTimeBarFilter);
+ }
+
+ if (this.selfTimeRange.start !== null || this.selfTimeRange.end !== null) {
+ filtersToAdd.push(this._selfTimeBarFilter);
+ }
+
const isBottomUp = this.viewMode === 'bottom-up';
if (!isBottomUp && this.filterState.debugOnly) {
@@ -831,21 +919,31 @@ export class CalltreeView extends LitElement {
this.typeFilterCache,
);
- _namespaceFilter = (
- selectedNamespaces: string[],
- _namespace: string,
- data: TimeOrderRow | AggregatedRow | BottomUpRow,
- filterParams: { filterCache: Map },
- ): boolean => {
- if (selectedNamespaces.length === 0) {
- return true;
- }
- return deepFilter(
+ _namespaceBarFilter = (data: TimeOrderRow | AggregatedRow | BottomUpRow): boolean =>
+ deepFilter(
data,
- (row) => selectedNamespaces.includes(row.namespace || ''),
- filterParams.filterCache,
+ (row) => this.namespaceSelected.includes(row.namespace || ''),
+ this.namespaceFilterCache,
+ );
+
+ _totalTimeBarFilter = (data: TimeOrderRow | AggregatedRow | BottomUpRow): boolean =>
+ deepFilter(
+ data,
+ (row) =>
+ inMsRange(this.totalTimeRange, 'totalTime' in row ? row.totalTime : row.duration.total),
+ this.totalTimeFilterCache,
+ );
+
+ _selfTimeBarFilter = (data: TimeOrderRow | AggregatedRow | BottomUpRow): boolean =>
+ deepFilter(
+ data,
+ (row) =>
+ inMsRange(
+ this.selfTimeRange,
+ 'totalSelfTime' in row ? row.totalSelfTime : row.duration.self,
+ ),
+ this.selfTimeFilterCache,
);
- };
private async _renderCallTree(
callTreeTableContainer: HTMLDivElement,
@@ -858,10 +956,12 @@ export class CalltreeView extends LitElement {
const { table, tableBuilt } = createTimeOrderTable(callTreeTableContainer, rootMethod, {
showDetailsFilter: this._showDetailsFilter,
- namespaceFilter: this._namespaceFilter,
onFilterCacheClear: () => {
this.debugOnlyFilterCache.clear();
this.typeFilterCache.clear();
+ this.namespaceFilterCache.clear();
+ this.totalTimeFilterCache.clear();
+ this.selfTimeFilterCache.clear();
},
onRenderStarted: () => {
if (!this.blockClearHighlights && this.totalMatches > 0) {
@@ -894,11 +994,13 @@ export class CalltreeView extends LitElement {
}
const { table, tableBuilt } = createAggregatedTable(container, rootMethod, {
- namespaceFilter: this._namespaceFilter,
showDetailsFilter: this._showDetailsFilter,
onFilterCacheClear: () => {
this.debugOnlyFilterCache.clear();
this.typeFilterCache.clear();
+ this.namespaceFilterCache.clear();
+ this.totalTimeFilterCache.clear();
+ this.selfTimeFilterCache.clear();
},
onRenderStarted: () => {
if (!this.blockClearHighlights && this.totalMatches > 0) {
@@ -923,7 +1025,6 @@ export class CalltreeView extends LitElement {
container,
rootMethod,
{
- namespaceFilter: this._namespaceFilter,
showDetailsFilter: this._showDetailsFilter,
onRenderStarted: () => {
if (!this.blockClearHighlights && this.totalMatches > 0) {
diff --git a/log-viewer/src/features/call-tree/components/TableShared.ts b/log-viewer/src/features/call-tree/components/TableShared.ts
index 504896f5..6f32efb4 100644
--- a/log-viewer/src/features/call-tree/components/TableShared.ts
+++ b/log-viewer/src/features/call-tree/components/TableShared.ts
@@ -11,18 +11,10 @@ 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 { makeSumFieldAllVisible } from '../utils/BottomCalcs.js';
import { governorCostBreakdown, type GovernorCostRow } from '../utils/GovernorCost.js';
-import type { TimeOrderRow } from '../utils/TimeOrderTree.js';
export interface TableCallbacks {
- namespaceFilter: (
- selectedNamespaces: string[],
- namespace: string,
- data: TimeOrderRow | AggregatedRow | BottomUpRow,
- filterParams: { filterCache: Map },
- ) => boolean;
onFilterCacheClear?: () => void;
onRenderStarted: () => void;
rowFormatter?: (row: RowComponent) => void;
diff --git a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts
index a37c04fb..6e258973 100644
--- a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts
+++ b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts
@@ -6,8 +6,6 @@ import { Tabulator, type RowComponent } from 'tabulator-tables';
import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js';
import { formatDuration } from '../../../core/utility/Util.js';
-import MinMaxEditor from '../../../tabulator/editors/MinMax.js';
-import { minMaxTreeFilter } from '../../../tabulator/filters/MinMax.js';
import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js';
import { VirtualVerticalRenderer } from '../../../tabulator/renderer/VirtualVerticalRenderer.js';
import { makeSumSelfTimeAllVisible } from '../utils/BottomCalcs.js';
@@ -34,10 +32,6 @@ export function createTimeOrderTable(
): { table: Tabulator; tableBuilt: Promise } {
registerTableModules();
- const selfTimeFilterCache = new Map();
- const totalTimeFilterCache = new Map();
- const namespaceFilterCache = new Map();
-
const excludedTypes = new Set(['SOQL_EXECUTE_BEGIN', 'DML_BEGIN']);
const governorLimits = rootMethod.governorLimits;
@@ -107,15 +101,6 @@ export function createTimeOrderTable(
sorter: 'string',
width: 100,
minWidth: 80,
- headerFilter: 'list',
- headerFilterFunc: callbacks.namespaceFilter,
- headerFilterFuncParams: { filterCache: namespaceFilterCache },
- headerFilterParams: {
- values: rootMethod.namespaces,
- clearable: true,
- multiselect: true,
- },
- headerFilterLiveFilter: false,
},
{
title: 'Caller Namespace',
@@ -142,10 +127,6 @@ export function createTimeOrderTable(
bottomCalcFormatter: progressFormatterMS,
bottomCalc: 'sum',
bottomCalcFormatterParams: { precision: 2, totalValue: rootMethod.duration.total },
- headerFilter: MinMaxEditor,
- headerFilterFunc: minMaxTreeFilter,
- headerFilterFuncParams: { columnName: 'duration.total', filterCache: totalTimeFilterCache },
- headerFilterLiveFilter: false,
tooltip(_event, cell, _onRender) {
return formatDuration(cell.getValue());
},
@@ -166,13 +147,6 @@ export function createTimeOrderTable(
precision: 2,
totalValue: rootMethod.duration.total,
},
- headerFilter: MinMaxEditor,
- headerFilterFunc: minMaxTreeFilter,
- headerFilterFuncParams: {
- columnName: 'duration.self',
- filterCache: selfTimeFilterCache,
- },
- headerFilterLiveFilter: false,
tooltip(_event, cell, _onRender) {
return formatDuration(cell.getValue());
},
@@ -181,18 +155,14 @@ export function createTimeOrderTable(
});
tableRef.current = table;
- // Filter caches are cleared once per render via `renderStarted`. Row ids
- // produced by `toTimeOrderTree` are globally unique within a build
- // (per-build monotonic counter), so cached `deepFilter` results stay valid
- // across the cascaded `filter.filter()` passes Tabulator runs for each
- // expanded subtree β `getChildren` β `filter.filter(config.children)`
- // would otherwise fire `dataFiltered` multiple times per user action,
- // defeating the cache. If row ids ever lose their uniqueness guarantee
- // this must move back to `dataFiltered`.
+ // The host's filter caches (search/type/debug/namespace/duration) are cleared once per
+ // render via `renderStarted` β see CalltreeView's `onFilterCacheClear`. Row ids produced
+ // by `toTimeOrderTree` are globally unique within a build (per-build monotonic counter),
+ // so cached `deepFilter` results stay valid across the cascaded `filter.filter()` passes
+ // Tabulator runs for each expanded subtree β `getChildren` β `filter.filter(config.children)`
+ // would otherwise fire `dataFiltered` multiple times per user action, defeating the cache.
+ // If row ids ever lose their uniqueness guarantee this must move back to `dataFiltered`.
table.on('renderStarted', () => {
- totalTimeFilterCache.clear();
- selfTimeFilterCache.clear();
- namespaceFilterCache.clear();
callbacks.onFilterCacheClear?.();
callbacks.onRenderStarted();
});
diff --git a/log-viewer/src/features/database/components/DMLView.ts b/log-viewer/src/features/database/components/DMLView.ts
index e73ef012..efd8d311 100644
--- a/log-viewer/src/features/database/components/DMLView.ts
+++ b/log-viewer/src/features/database/components/DMLView.ts
@@ -25,8 +25,9 @@ import {
// Tabulator custom modules, imports + styles
import NumberAccessor from '../../../tabulator/dataaccessor/Number.js';
-import Number from '../../../tabulator/format/Number.js';
+import { inCountRange, inMsRange, type FilterRange } from '../../../tabulator/filters/MinMax.js';
import { progressFormatter } from '../../../tabulator/format/Progress.js';
+import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js';
import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js';
import { GroupChildIndent } from '../../../tabulator/groups/GroupChildIndent.js';
import { GroupSort } from '../../../tabulator/groups/GroupSort.js';
@@ -35,6 +36,7 @@ import { Find } from '../../../tabulator/module/Find.js';
import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js';
import { RowNavigation } from '../../../tabulator/module/RowNavigation.js';
import dataGridStyles from '../../../tabulator/style/DataGrid.scss';
+import { commonColumnDefaults, headerSortElement } from '../../call-tree/components/TableShared.js';
// styles
import { globalStyles } from '../../../styles/global.styles.js';
@@ -44,7 +46,10 @@ import databaseViewStyles from './DatabaseView.scss';
import '../../../components/CallStack.js';
import '../../../components/ContextMenu.js';
import type { ContextMenu } from '../../../components/ContextMenu.js';
+import '../../../components/datagrid-facet-filter.js';
import '../../../components/datagrid-filter-bar.js';
+import '../../../components/datagrid-range-filter.js';
+import '../../../components/OverflowList.js';
/** The DML column is always shown in the DML table. */
const ALWAYS_VISIBLE = ['dml'];
@@ -92,6 +97,15 @@ export class DMLView extends LitElement {
private columnOverrides: Record = {};
private contextMenu: ContextMenu | null = null;
+ @state()
+ private callerNamespaces: string[] = [];
+ @state()
+ private objects: string[] = [];
+ private callerNamespaceSelected: string[] = [];
+ private objectSelected: string[] = [];
+ private rowCountRange: FilterRange = { start: null, end: null };
+ private timeTakenRange: FilterRange = { start: null, end: null };
+
constructor() {
super();
@@ -158,7 +172,30 @@ export class DMLView extends LitElement {
return html`
+
+
+
+
+
+
+
) {
+ this.callerNamespaceSelected = event.detail.selected;
+ this.dmlTable?.refreshFilter();
+ }
+
+ private _handleObjectFacet(event: CustomEvent<{ selected: string[] }>) {
+ this.objectSelected = event.detail.selected;
+ this.dmlTable?.refreshFilter();
+ }
+
+ private _handleRowCountRange(event: CustomEvent<{ range: FilterRange }>) {
+ this.rowCountRange = event.detail.range;
+ this.dmlTable?.refreshFilter();
+ }
+
+ private _handleTimeTakenRange(event: CustomEvent<{ range: FilterRange }>) {
+ this.timeTakenRange = event.detail.range;
+ this.dmlTable?.refreshFilter();
+ }
+
+ private _callerNamespaceFilter = (data: DMLRow): boolean =>
+ !!data.isDetail ||
+ this.callerNamespaceSelected.length === 0 ||
+ this.callerNamespaceSelected.includes(data.callerNamespace ?? '');
+
+ private _objectFilter = (data: DMLRow): boolean =>
+ !!data.isDetail ||
+ this.objectSelected.length === 0 ||
+ this.objectSelected.includes(data.objectType ?? '');
+
+ private _rowCountFilter = (data: DMLRow): boolean =>
+ !!data.isDetail || inCountRange(this.rowCountRange, data.rowCount ?? 0);
+
+ private _timeTakenFilter = (data: DMLRow): boolean =>
+ !!data.isDetail || inMsRange(this.timeTakenRange, data.timeTaken ?? 0);
+
_copyToClipboard() {
this.dmlTable?.copyToClipboard('all');
}
@@ -438,7 +512,6 @@ 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) {
@@ -462,6 +535,17 @@ export class DMLView extends LitElement {
}
}
+ // Bars fill relative to this grid's own totals (the Total row's sum), not a governor limit.
+ const dmlRowCountTotal = dmlData.reduce((sum, row) => sum + (row.rowCount ?? 0), 0);
+ const dmlTimeTakenTotal = dmlData.reduce((sum, row) => sum + (row.timeTaken ?? 0), 0);
+
+ this.callerNamespaces = [
+ ...new Set(dmlData.map((row) => row.callerNamespace).filter((v): v is string => !!v)),
+ ].sort();
+ this.objects = [
+ ...new Set(dmlData.map((row) => row.objectType).filter((v): v is string => !!v)),
+ ].sort();
+
this.dmlTable = new Tabulator(dmlTableContainer, {
index: 'id',
height: '100%',
@@ -495,28 +579,14 @@ export class DMLView extends LitElement {
dataTree: true,
dataTreeBranchElement: false,
dataTreeStartExpanded: false,
- columnDefaults: {
- title: 'default',
- resizable: true,
- headerSortStartingDir: 'desc',
- headerTooltip: true,
- headerWordWrap: true,
- },
- headerSortElement: function (_column, dir) {
- switch (dir) {
- case 'asc':
- return "";
- case 'desc':
- return "";
- default:
- return "";
- }
- },
+ columnDefaults: commonColumnDefaults,
+ headerSortElement,
columns: [
{
title: 'DML',
field: 'dml',
sorter: 'string',
+ widthGrow: 5,
bottomCalc: () => {
return 'Total';
},
@@ -536,37 +606,21 @@ export class DMLView extends LitElement {
title: 'Caller Namespace',
field: 'callerNamespace',
sorter: 'string',
- width: 120,
- headerFilter: 'list',
- headerFilterFunc: 'in',
- headerFilterParams: {
- valuesLookup: 'all',
- clearable: true,
- multiselect: true,
- },
- headerFilterLiveFilter: false,
+ width: 100,
},
{
title: 'Object',
field: 'objectType',
sorter: 'string',
- width: 150,
+ width: 110,
visible: false,
- headerFilter: 'list',
- headerFilterFunc: 'in',
- headerFilterParams: {
- valuesLookup: 'all',
- clearable: true,
- multiselect: true,
- },
- headerFilterLiveFilter: false,
formatter: (cell) => (cell.getValue() as string | null) ?? 'β',
},
{
title: 'Namespace',
field: 'namespace',
sorter: 'string',
- width: 120,
+ width: 100,
visible: false,
},
{
@@ -578,15 +632,20 @@ export class DMLView extends LitElement {
hozAlign: 'right',
headerHozAlign: 'right',
formatter: progressFormatter,
- formatterParams: { precision: 0, totalValue: dmlRowLimit, showPercentageText: false },
+ formatterParams: {
+ precision: 0,
+ totalValue: dmlRowCountTotal,
+ showPercentageText: false,
+ },
bottomCalc: 'sum',
bottomCalcFormatter: progressFormatter,
bottomCalcFormatterParams: {
precision: 0,
- totalValue: dmlRowLimit,
+ totalValue: dmlRowCountTotal,
showPercentageText: false,
},
- tooltip: (_e, cell) => cell.getValue() + (dmlRowLimit > 0 ? '/' + dmlRowLimit : ''),
+ tooltip: (_e, cell) =>
+ cell.getValue() + (dmlRowCountTotal > 0 ? '/' + dmlRowCountTotal : ''),
},
{
title: 'Time Taken (ms)',
@@ -596,15 +655,20 @@ export class DMLView extends LitElement {
width: 110,
hozAlign: 'right',
headerHozAlign: 'right',
- formatter: Number,
+ formatter: progressFormatterMS,
formatterParams: {
- thousand: false,
precision: 2,
+ totalValue: dmlTimeTakenTotal,
+ showPercentageText: false,
},
accessorDownload: NumberAccessor,
- bottomCalcFormatter: Number,
bottomCalc: 'sum',
- bottomCalcFormatterParams: { precision: 2 },
+ bottomCalcFormatter: progressFormatterMS,
+ bottomCalcFormatterParams: {
+ precision: 2,
+ totalValue: dmlTimeTakenTotal,
+ showPercentageText: false,
+ },
},
],
rowFormatter: (row) => {
@@ -657,6 +721,10 @@ export class DMLView extends LitElement {
this.dmlTable?.setSortedGroupBy('dml');
if (this.dmlTable) {
this._initTableColumns(this.dmlTable);
+ this.dmlTable.addFilter(this._callerNamespaceFilter);
+ this.dmlTable.addFilter(this._objectFilter);
+ this.dmlTable.addFilter(this._rowCountFilter);
+ this.dmlTable.addFilter(this._timeTakenFilter);
}
});
diff --git a/log-viewer/src/features/database/components/SOQLView.ts b/log-viewer/src/features/database/components/SOQLView.ts
index 14aa09cc..f2b44c09 100644
--- a/log-viewer/src/features/database/components/SOQLView.ts
+++ b/log-viewer/src/features/database/components/SOQLView.ts
@@ -33,8 +33,9 @@ import {
// Tabulator custom modules, imports + styles
import NumberAccessor from '../../../tabulator/dataaccessor/Number.js';
-import Number from '../../../tabulator/format/Number.js';
+import { inCountRange, inMsRange, type FilterRange } from '../../../tabulator/filters/MinMax.js';
import { progressFormatter } from '../../../tabulator/format/Progress.js';
+import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js';
import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js';
import { GroupChildIndent } from '../../../tabulator/groups/GroupChildIndent.js';
import { GroupSort } from '../../../tabulator/groups/GroupSort.js';
@@ -43,6 +44,7 @@ import { Find } from '../../../tabulator/module/Find.js';
import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js';
import { RowNavigation } from '../../../tabulator/module/RowNavigation.js';
import dataGridStyles from '../../../tabulator/style/DataGrid.scss';
+import { commonColumnDefaults, headerSortElement } from '../../call-tree/components/TableShared.js';
// styles
import { globalStyles } from '../../../styles/global.styles.js';
@@ -52,7 +54,10 @@ import databaseViewStyles from './DatabaseView.scss';
import '../../../components/CallStack.js';
import '../../../components/ContextMenu.js';
import type { ContextMenu } from '../../../components/ContextMenu.js';
+import '../../../components/datagrid-facet-filter.js';
import '../../../components/datagrid-filter-bar.js';
+import '../../../components/datagrid-range-filter.js';
+import '../../../components/OverflowList.js';
import './DatabaseSOQLDetailPanel.js';
/** The SOQL column is always shown in the SOQL table. */
@@ -94,6 +99,16 @@ export class SOQLView extends LitElement {
@state()
private columnOverrides: Record = {};
private contextMenu: ContextMenu | null = null;
+
+ @state()
+ private objects: string[] = [];
+ @state()
+ private namespaces: string[] = [];
+ private objectSelected: string[] = [];
+ private namespaceSelected: string[] = [];
+ private rowCountRange: FilterRange = { start: null, end: null };
+ private timeTakenRange: FilterRange = { start: null, end: null };
+
findArgs: { text: string; count: number; options: { matchCase: boolean } } = {
text: '',
count: 0,
@@ -172,7 +187,30 @@ export class SOQLView extends LitElement {
const soqlSkeleton = !this.timelineRoot ? html`` : ``;
return html`
+
+
+
+
+
+
+
) {
+ this.objectSelected = event.detail.selected;
+ this.soqlTable?.refreshFilter();
+ }
+
+ private _handleNamespaceFacet(event: CustomEvent<{ selected: string[] }>) {
+ this.namespaceSelected = event.detail.selected;
+ this.soqlTable?.refreshFilter();
+ }
+
+ private _handleRowCountRange(event: CustomEvent<{ range: FilterRange }>) {
+ this.rowCountRange = event.detail.range;
+ this.soqlTable?.refreshFilter();
+ }
+
+ private _handleTimeTakenRange(event: CustomEvent<{ range: FilterRange }>) {
+ this.timeTakenRange = event.detail.range;
+ this.soqlTable?.refreshFilter();
+ }
+
+ private _objectFilter = (data: GridSOQLData): boolean =>
+ !!data.isDetail ||
+ this.objectSelected.length === 0 ||
+ this.objectSelected.includes(data.objectType ?? '');
+
+ private _namespaceFilter = (data: GridSOQLData): boolean =>
+ !!data.isDetail ||
+ this.namespaceSelected.length === 0 ||
+ this.namespaceSelected.includes(data.namespace ?? '');
+
+ private _rowCountFilter = (data: GridSOQLData): boolean =>
+ !!data.isDetail || inCountRange(this.rowCountRange, data.rowCount ?? 0);
+
+ private _timeTakenFilter = (data: GridSOQLData): boolean =>
+ !!data.isDetail || inMsRange(this.timeTakenRange, data.timeTaken ?? 0);
+
_copyToClipboard() {
this.soqlTable?.copyToClipboard('all');
}
@@ -448,7 +523,6 @@ 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) => {
@@ -487,6 +561,17 @@ export class SOQLView extends LitElement {
}
}
+ this.objects = [
+ ...new Set(soqlData.map((row) => row.objectType).filter((v): v is string => !!v)),
+ ].sort();
+ this.namespaces = [
+ ...new Set(soqlData.map((row) => row.namespace).filter((v): v is string => !!v)),
+ ].sort();
+
+ // Bars fill relative to this grid's own totals (the Total row's sum), not a governor limit.
+ const soqlRowCountTotal = soqlData.reduce((sum, row) => sum + (row.rowCount ?? 0), 0);
+ const soqlTimeTakenTotal = soqlData.reduce((sum, row) => sum + (row.timeTaken ?? 0), 0);
+
this.soqlTable = new Tabulator(soqlTableContainer, {
index: 'id',
height: '100%',
@@ -521,23 +606,8 @@ export class SOQLView extends LitElement {
dataTree: true,
dataTreeBranchElement: false,
dataTreeStartExpanded: false,
- columnDefaults: {
- title: 'default',
- resizable: true,
- headerSortStartingDir: 'desc',
- headerTooltip: true,
- headerWordWrap: true,
- },
- headerSortElement: function (_column, dir) {
- switch (dir) {
- case 'asc':
- return "";
- case 'desc':
- return "";
- default:
- return "";
- }
- },
+ columnDefaults: commonColumnDefaults,
+ headerSortElement,
columns: [
{
title: 'SOQL',
@@ -545,6 +615,7 @@ export class SOQLView extends LitElement {
headerSortStartingDir: 'asc',
sorter: 'string',
tooltip: true,
+ widthGrow: 5,
bottomCalc: () => {
return 'Total';
},
@@ -624,37 +695,21 @@ export class SOQLView extends LitElement {
title: 'Object',
field: 'objectType',
sorter: 'string',
- width: 150,
+ width: 110,
visible: false,
- headerFilter: 'list',
- headerFilterFunc: 'in',
- headerFilterParams: {
- valuesLookup: 'all',
- clearable: true,
- multiselect: true,
- },
- headerFilterLiveFilter: false,
formatter: (cell) => (cell.getValue() as string | null) ?? 'β',
},
{
title: 'Namespace',
field: 'namespace',
sorter: 'string',
- width: 120,
- headerFilter: 'list',
- headerFilterFunc: 'in',
- headerFilterParams: {
- valuesLookup: 'all',
- clearable: true,
- multiselect: true,
- },
- headerFilterLiveFilter: false,
+ width: 100,
},
{
title: 'Caller Namespace',
field: 'callerNamespace',
sorter: 'string',
- width: 120,
+ width: 100,
visible: false,
},
{
@@ -666,15 +721,20 @@ export class SOQLView extends LitElement {
hozAlign: 'right',
headerHozAlign: 'right',
formatter: progressFormatter,
- formatterParams: { precision: 0, totalValue: queryRowLimit, showPercentageText: false },
+ formatterParams: {
+ precision: 0,
+ totalValue: soqlRowCountTotal,
+ showPercentageText: false,
+ },
bottomCalc: 'sum',
bottomCalcFormatter: progressFormatter,
bottomCalcFormatterParams: {
precision: 0,
- totalValue: queryRowLimit,
+ totalValue: soqlRowCountTotal,
showPercentageText: false,
},
- tooltip: (_e, cell) => cell.getValue() + (queryRowLimit > 0 ? '/' + queryRowLimit : ''),
+ tooltip: (_e, cell) =>
+ cell.getValue() + (soqlRowCountTotal > 0 ? '/' + soqlRowCountTotal : ''),
},
{
title: 'Aggregations',
@@ -746,15 +806,20 @@ export class SOQLView extends LitElement {
width: 120,
hozAlign: 'right',
headerHozAlign: 'right',
- formatter: Number,
+ formatter: progressFormatterMS,
formatterParams: {
- thousand: false,
precision: 2,
+ totalValue: soqlTimeTakenTotal,
+ showPercentageText: false,
},
accessorDownload: NumberAccessor,
- bottomCalcFormatter: Number,
bottomCalc: 'sum',
- bottomCalcFormatterParams: { precision: 2 },
+ bottomCalcFormatter: progressFormatterMS,
+ bottomCalcFormatterParams: {
+ precision: 2,
+ totalValue: soqlTimeTakenTotal,
+ showPercentageText: false,
+ },
},
],
rowFormatter: (row) => {
@@ -807,6 +872,10 @@ export class SOQLView extends LitElement {
this.soqlTable?.setSortedGroupBy('soql');
if (this.soqlTable) {
this._initTableColumns(this.soqlTable);
+ this.soqlTable.addFilter(this._objectFilter);
+ this.soqlTable.addFilter(this._namespaceFilter);
+ this.soqlTable.addFilter(this._rowCountFilter);
+ this.soqlTable.addFilter(this._timeTakenFilter);
}
});
diff --git a/log-viewer/src/features/database/components/SOSLView.ts b/log-viewer/src/features/database/components/SOSLView.ts
index 6f5fa055..fbcc7e1f 100644
--- a/log-viewer/src/features/database/components/SOSLView.ts
+++ b/log-viewer/src/features/database/components/SOSLView.ts
@@ -25,9 +25,9 @@ import {
// Tabulator custom modules, imports + styles
import NumberAccessor from '../../../tabulator/dataaccessor/Number.js';
-import Number from '../../../tabulator/format/Number.js';
+import { inCountRange, inMsRange, type FilterRange } from '../../../tabulator/filters/MinMax.js';
import { progressFormatter } from '../../../tabulator/format/Progress.js';
-import { SOSL_ROWS_PER_QUERY_LIMIT } from '../limits.js';
+import { progressFormatterMS } from '../../../tabulator/format/ProgressMS.js';
import { GroupCalcs } from '../../../tabulator/groups/GroupCalcs.js';
import { GroupChildIndent } from '../../../tabulator/groups/GroupChildIndent.js';
import { GroupSort } from '../../../tabulator/groups/GroupSort.js';
@@ -36,6 +36,7 @@ import { Find } from '../../../tabulator/module/Find.js';
import { RowKeyboardNavigation } from '../../../tabulator/module/RowKeyboardNavigation.js';
import { RowNavigation } from '../../../tabulator/module/RowNavigation.js';
import dataGridStyles from '../../../tabulator/style/DataGrid.scss';
+import { commonColumnDefaults, headerSortElement } from '../../call-tree/components/TableShared.js';
// styles
import { globalStyles } from '../../../styles/global.styles.js';
@@ -45,7 +46,10 @@ import databaseViewStyles from './DatabaseView.scss';
import '../../../components/CallStack.js';
import '../../../components/ContextMenu.js';
import type { ContextMenu } from '../../../components/ContextMenu.js';
+import '../../../components/datagrid-facet-filter.js';
import '../../../components/datagrid-filter-bar.js';
+import '../../../components/datagrid-range-filter.js';
+import '../../../components/OverflowList.js';
/** The SOSL column is always shown in the SOSL table. */
const ALWAYS_VISIBLE = ['sosl'];
@@ -57,8 +61,6 @@ const groupLabelsToFields = new Map([
['None', ''],
]);
-const countFormat = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
-
@customElement('sosl-view')
export class SOSLView extends LitElement {
@property()
@@ -94,6 +96,12 @@ export class SOSLView extends LitElement {
private columnOverrides: Record = {};
private contextMenu: ContextMenu | null = null;
+ @state()
+ private namespaces: string[] = [];
+ private namespaceSelected: string[] = [];
+ private rowCountRange: FilterRange = { start: null, end: null };
+ private timeTakenRange: FilterRange = { start: null, end: null };
+
constructor() {
super();
@@ -160,7 +168,25 @@ export class SOSLView extends LitElement {
return html`
+
+
+
+
+
+
) {
+ this.namespaceSelected = event.detail.selected;
+ this.soslTable?.refreshFilter();
+ }
+
+ private _handleRowCountRange(event: CustomEvent<{ range: FilterRange }>) {
+ this.rowCountRange = event.detail.range;
+ this.soslTable?.refreshFilter();
+ }
+
+ private _handleTimeTakenRange(event: CustomEvent<{ range: FilterRange }>) {
+ this.timeTakenRange = event.detail.range;
+ this.soslTable?.refreshFilter();
+ }
+
+ private _namespaceFilter = (data: SOSLRow): boolean =>
+ !!data.isDetail ||
+ this.namespaceSelected.length === 0 ||
+ this.namespaceSelected.includes(data.namespace ?? '');
+
+ private _rowCountFilter = (data: SOSLRow): boolean =>
+ !!data.isDetail || inCountRange(this.rowCountRange, data.rowCount ?? 0);
+
+ private _timeTakenFilter = (data: SOSLRow): boolean =>
+ !!data.isDetail || inMsRange(this.timeTakenRange, data.timeTaken ?? 0);
+
_copyToClipboard() {
this.soslTable?.copyToClipboard('all');
}
@@ -460,6 +513,14 @@ export class SOSLView extends LitElement {
}
}
+ this.namespaces = [
+ ...new Set(soslData.map((row) => row.namespace).filter((v): v is string => !!v)),
+ ].sort();
+
+ // Bars fill relative to this grid's own totals (the Total row's sum), not a governor limit.
+ const soslRowCountTotal = soslData.reduce((sum, row) => sum + (row.rowCount ?? 0), 0);
+ const soslTimeTakenTotal = soslData.reduce((sum, row) => sum + (row.timeTaken ?? 0), 0);
+
this.soslTable = new Tabulator(soslTableContainer, {
index: 'id',
height: '100%',
@@ -493,28 +554,14 @@ export class SOSLView extends LitElement {
dataTree: true,
dataTreeBranchElement: false,
dataTreeStartExpanded: false,
- columnDefaults: {
- title: 'default',
- resizable: true,
- headerSortStartingDir: 'desc',
- headerTooltip: true,
- headerWordWrap: true,
- },
- headerSortElement: function (_column, dir) {
- switch (dir) {
- case 'asc':
- return "";
- case 'desc':
- return "";
- default:
- return "";
- }
- },
+ columnDefaults: commonColumnDefaults,
+ headerSortElement,
columns: [
{
title: 'SOSL',
field: 'sosl',
sorter: 'string',
+ widthGrow: 5,
bottomCalc: () => {
return 'Total';
},
@@ -534,26 +581,16 @@ export class SOSLView extends LitElement {
title: 'Namespace',
field: 'namespace',
sorter: 'string',
- width: 120,
- headerFilter: 'list',
- headerFilterFunc: 'in',
- headerFilterParams: {
- valuesLookup: 'all',
- clearable: true,
- multiselect: true,
- },
- headerFilterLiveFilter: false,
+ width: 100,
},
{
title: 'Caller Namespace',
field: 'callerNamespace',
sorter: 'string',
- width: 120,
+ width: 100,
visible: false,
},
{
- // SOSL's row limit is per query (2,000), not a transaction total β so
- // each row meters against that per-query cap; the footer is a plain sum.
title: 'Row Count',
field: 'rowCount',
sorter: 'number',
@@ -564,14 +601,18 @@ export class SOSLView extends LitElement {
formatter: progressFormatter,
formatterParams: {
precision: 0,
- totalValue: SOSL_ROWS_PER_QUERY_LIMIT,
+ totalValue: soslRowCountTotal,
showPercentageText: false,
},
- // The group/total is a plain sum (a per-query bar there would be
- // meaningless); use an integer formatter, NOT the nsβms `Number` one.
bottomCalc: 'sum',
- bottomCalcFormatter: (cell) => countFormat.format((cell.getValue() as number) ?? 0),
- tooltip: (_e, cell) => `${cell.getValue()} / ${SOSL_ROWS_PER_QUERY_LIMIT} per query`,
+ bottomCalcFormatter: progressFormatter,
+ bottomCalcFormatterParams: {
+ precision: 0,
+ totalValue: soslRowCountTotal,
+ showPercentageText: false,
+ },
+ tooltip: (_e, cell) =>
+ cell.getValue() + (soslRowCountTotal > 0 ? '/' + soslRowCountTotal : ''),
},
{
title: 'Time Taken (ms)',
@@ -581,15 +622,20 @@ export class SOSLView extends LitElement {
width: 110,
hozAlign: 'right',
headerHozAlign: 'right',
- formatter: Number,
+ formatter: progressFormatterMS,
formatterParams: {
- thousand: false,
precision: 2,
+ totalValue: soslTimeTakenTotal,
+ showPercentageText: false,
},
accessorDownload: NumberAccessor,
- bottomCalcFormatter: Number,
bottomCalc: 'sum',
- bottomCalcFormatterParams: { precision: 2 },
+ bottomCalcFormatter: progressFormatterMS,
+ bottomCalcFormatterParams: {
+ precision: 2,
+ totalValue: soslTimeTakenTotal,
+ showPercentageText: false,
+ },
},
],
rowFormatter: (row) => {
@@ -642,6 +688,9 @@ export class SOSLView extends LitElement {
this.soslTable?.setSortedGroupBy('sosl');
if (this.soslTable) {
this._initTableColumns(this.soslTable);
+ this.soslTable.addFilter(this._namespaceFilter);
+ this.soslTable.addFilter(this._rowCountFilter);
+ this.soslTable.addFilter(this._timeTakenFilter);
}
});
diff --git a/log-viewer/src/styles/global.styles.ts b/log-viewer/src/styles/global.styles.ts
index 8a519f73..db43a408 100644
--- a/log-viewer/src/styles/global.styles.ts
+++ b/log-viewer/src/styles/global.styles.ts
@@ -1,6 +1,32 @@
import { css } from 'lit';
export const globalStyles = css`
+ :host {
+ /* Filter-bar design tokens β every compact control (facet/range pills,
+ toggle pills, dense VsSelect, Expand/Collapse, view-mode buttons) reads
+ off these so the bar is one visual family instead of N hand-tuned
+ copies. */
+ --filter-control-height: 22px;
+ --filter-control-font-size: 11px;
+ --filter-control-padding: 0 8px;
+ --filter-control-radius: 4px;
+ --filter-control-border-color: var(--vscode-settings-dropdownBorder, #3c3c3c);
+ --filter-control-bg: var(--vscode-settings-dropdownBackground, #313131);
+
+ /* Popover design tokens β facet/range/select/context-menu popovers all
+ consume these so they render as one family. */
+ --filter-popover-bg: var(--vscode-menu-background, var(--vscode-editor-background));
+ --filter-popover-border-color: var(--vscode-menu-border, var(--divider-background));
+ --filter-popover-radius: 6px;
+ --filter-popover-shadow: 0 4px 16px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.36));
+ --filter-popover-row-font-size: 12px;
+ --filter-popover-row-padding: 4px 8px;
+
+ /* Label column width shared by every filter's overflow-panel row (facet,
+ range, select) so their labels + controls line up as one form. */
+ --filter-panel-label-width: 6.5rem;
+ }
+
a {
color: var(--vscode-textLink-foreground);
text-decoration: none;
@@ -55,4 +81,127 @@ export const globalStyles = css`
vscode-button[secondary]:focus:not(:hover)::part(base) {
background-color: var(--vscode-button-secondaryBackground);
}
+
+ /* Native checkbox: box background/border never change on check (matching
+ vscode-checkbox) β only a currentColor tick appears on top, drawn with
+ the same tick path vscode-checkbox uses via mask-image so it scales
+ cleanly instead of being clipped out of a filled square. */
+ .vs-checkbox {
+ box-sizing: border-box;
+ appearance: none;
+ margin: 0;
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ border: 1px solid var(--vscode-checkbox-border, var(--vscode-settings-dropdownBorder, #6b6b6b));
+ border-radius: 3px;
+ background-color: var(--vscode-checkbox-background, #313131);
+ cursor: pointer;
+ }
+
+ .vs-checkbox:checked::before {
+ content: '';
+ display: block;
+ width: 100%;
+ height: 100%;
+ background-color: var(--vscode-checkbox-foreground);
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M14.431 3.323l-8.47 10-.79-.036-3.35-4.77.818-.574 2.978 4.24 8.051-9.506.764.646z'/%3E%3C/svg%3E");
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M14.431 3.323l-8.47 10-.79-.036-3.35-4.77.818-.574 2.978 4.24 8.051-9.506.764.646z'/%3E%3C/svg%3E");
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ -webkit-mask-position: center;
+ mask-position: center;
+ -webkit-mask-size: 70%;
+ mask-size: 70%;
+ }
+
+ .vs-checkbox:focus-visible {
+ outline: 1px solid var(--vscode-focusBorder);
+ outline-offset: 1px;
+ }
+
+ .vs-checkbox-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ cursor: pointer;
+ }
+
+ /* Shared base for every compact filter-bar control (facet/range trigger
+ pills, toggle pills, dense VsSelect face) β one place for the
+ height/padding/border/radius/hover/focus so they're never re-tuned
+ independently. Consumers add their own class alongside this one for
+ anything control-specific (active state, tabular numerals, etc). */
+ .filter-control {
+ box-sizing: border-box;
+ height: var(--filter-control-height);
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: var(--filter-control-padding);
+ border: 1px solid var(--filter-control-border-color);
+ border-radius: var(--filter-control-radius);
+ background-color: var(--filter-control-bg);
+ color: var(--vscode-foreground);
+ font: inherit;
+ font-size: var(--filter-control-font-size);
+ line-height: 1.4;
+ white-space: nowrap;
+ cursor: pointer;
+ }
+
+ .filter-control:hover {
+ background-color: var(--vscode-list-hoverBackground);
+ }
+
+ .filter-control:focus-visible {
+ outline: 1px solid var(--vscode-focusBorder);
+ outline-offset: 1px;
+ }
+
+ /* Toggle-button counterpart to the facet/range trigger pills β same
+ filter-control base, with an on/off state instead of a popover. ON uses
+ VS Code's own toggle triad (Find widget, VsIconCheckbox); OFF is just
+ the plain bordered pill, never dimmed. Binary toggles (Details/Debug
+ Only) get the full filled treatment β this is a real "state", not a
+ value-carrying filter. */
+ .pill-toggle[aria-pressed='true'] {
+ background-color: var(--vscode-inputOption-activeBackground);
+ border-color: var(--vscode-inputOption-activeBorder, var(--vscode-focusBorder));
+ color: var(--vscode-inputOption-activeForeground, var(--vscode-foreground));
+ }
+
+ /* Value-filter controls (facet/range triggers with a value, single-select
+ filters β default) get an accent border + text only β no fill. This
+ distinguishes "a filter is applied" from the binary toggle's ON state
+ above, while still reading as accented against the plain pill. */
+ .filter-control--active {
+ border-color: var(--vscode-inputOption-activeBorder, var(--vscode-focusBorder));
+ color: var(--vscode-inputOption-activeForeground, var(--vscode-foreground));
+ }
+
+ /* Shared base for every filter-bar popover (facet checklist, range inputs,
+ VsSelect dropdown, context menu) so they render as one family. Consumers
+ keep their own positioning (fixed/anchor/inset/margin/size) and add this
+ class for the look. */
+ .filter-popover {
+ background-color: var(--filter-popover-bg);
+ border: 1px solid var(--filter-popover-border-color);
+ border-radius: var(--filter-popover-radius);
+ box-shadow: var(--filter-popover-shadow);
+ color: var(--vscode-menu-foreground, var(--vscode-foreground));
+ font-family: var(--vscode-font-family);
+ }
+
+ .filter-popover-row {
+ padding: var(--filter-popover-row-padding);
+ border-radius: 4px;
+ font-size: var(--filter-popover-row-font-size);
+ cursor: pointer;
+ }
+
+ .filter-popover-row:hover {
+ background-color: var(--vscode-list-hoverBackground);
+ }
`;
diff --git a/log-viewer/src/tabulator/ColumnViews.ts b/log-viewer/src/tabulator/ColumnViews.ts
index 78f44e23..de61983d 100644
--- a/log-viewer/src/tabulator/ColumnViews.ts
+++ b/log-viewer/src/tabulator/ColumnViews.ts
@@ -101,10 +101,10 @@ export const CALL_TREE_VIEWS: ColumnView[] = [
export const SOQL_VIEWS: ColumnView[] = [
{
// Object is visible by default (its __mdt suffix flags the "does this count
- // toward the SOQL limit?" case of #162). The derived Counts column lives in
- // the focused Limits view to avoid duplicating that signal.
+ // toward the SOQL limit?" case of #162). isSelective/aggregations still
+ // available via the Performance view and the column menu.
id: 'General',
- fields: ['isSelective', 'objectType', 'namespace', 'rowCount', 'timeTaken', 'aggregations'],
+ fields: ['objectType', 'namespace', 'rowCount', 'timeTaken'],
},
{ id: 'Performance', fields: ['isSelective', 'relativeCost', 'rowCount', 'timeTaken'] },
{
@@ -246,7 +246,8 @@ export function buildColumnMenuItems(
const title = String(column.getDefinition().title ?? field);
items.push({
id: `col:${field}`,
- label: `${column.isVisible() ? CHECKED : UNCHECKED}${title}`,
+ label: title,
+ checked: column.isVisible(),
keepOpen: true,
});
}
diff --git a/log-viewer/src/tabulator/editors/MinMax.css b/log-viewer/src/tabulator/editors/MinMax.css
deleted file mode 100644
index f4eaef41..00000000
--- a/log-viewer/src/tabulator/editors/MinMax.css
+++ /dev/null
@@ -1,27 +0,0 @@
-.min-max__input {
- background-color: var(--vscode-settings-numberInputBackground);
- color: var(--vscode-settings-numberInputForeground);
- border: 1px solid var(--vscode-settings-numberInputBorder, transparent);
- width: 50%;
- box-sizing: border-box;
- position: relative;
- padding: 2px 4px;
- box-sizing: border-box;
- border-radius: 2px;
- font-size: inherit;
- appearance: textfield !important;
-}
-
-.min-max__input::-webkit-outer-spin-button,
-.min-max__input::-webkit-inner-spin-button {
- -webkit-appearance: none;
- margin: 0;
-}
-
-.min-max__input:focus {
- outline-width: 1px;
- outline-style: solid;
- outline-offset: -1px;
- outline-color: var(--vscode-focusBorder);
- opacity: 1;
-}
diff --git a/log-viewer/src/tabulator/editors/MinMax.ts b/log-viewer/src/tabulator/editors/MinMax.ts
deleted file mode 100644
index 4586013a..00000000
--- a/log-viewer/src/tabulator/editors/MinMax.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * Copyright (c) 2022 Certinia Inc. All rights reserved.
- */
-import type {
- CellComponent,
- EmptyCallback,
- ValueBooleanCallback,
- ValueVoidCallback,
-} from 'tabulator-tables';
-
-import './MinMax.css';
-
-export default function (
- cell: CellComponent,
- onRendered: EmptyCallback,
- success: ValueBooleanCallback,
- cancel: ValueVoidCallback,
- _editorParams: object,
-): HTMLElement | false {
- const container = document.createElement('span');
-
- //create and style inputs
- const start = document.createElement('input');
- start.min = '0';
- start.type = 'number';
- start.className = 'min-max__input input';
- start.placeholder = 'Min';
-
- const end = start.cloneNode() as HTMLInputElement;
- end.setAttribute('placeholder', 'Max');
-
- function buildValues() {
- start.step = getStep(start.value);
- end.step = getStep(end.value);
- success({
- start: start.value !== '' ? +start.value : null,
- end: end.value !== '' ? +end.value : null,
- });
- }
-
- function getStep(numValue: string) {
- let step = numValue.split('.')[1];
- if (step) {
- step = `0.${'0'.repeat(step.length - 1)}1`;
- } else {
- step = '1';
- }
- return step;
- }
-
- function keypress(e: KeyboardEvent) {
- if (e.key === 'Enter') {
- buildValues();
- } else if (e.key === 'Escape') {
- cancel(true);
- }
- }
-
- start.addEventListener('change', buildValues);
- start.addEventListener('blur', buildValues);
- start.addEventListener('keydown', keypress);
-
- end.addEventListener('change', buildValues);
- end.addEventListener('blur', buildValues);
- end.addEventListener('keydown', keypress);
-
- container.appendChild(start);
- container.appendChild(end);
-
- return container;
-}
diff --git a/log-viewer/src/tabulator/filters/MinMax.ts b/log-viewer/src/tabulator/filters/MinMax.ts
index 9854304b..1aee2318 100644
--- a/log-viewer/src/tabulator/filters/MinMax.ts
+++ b/log-viewer/src/tabulator/filters/MinMax.ts
@@ -2,62 +2,22 @@
* Copyright (c) 2022 Certinia Inc. All rights reserved.
*/
-type FilterRange = { start: number | null; end: number | null };
-
-type RowWithChildren = { _children?: unknown[]; id: number | string };
+export type FilterRange = { start: number | null; end: number | null };
const NS_PER_MS = 1_000_000;
-export default function (filterVal: FilterRange, rowVal: number): boolean {
- if (!('start' in filterVal) || !('end' in filterVal)) {
- return false;
- }
- return inRange(filterVal, rowVal);
+/** Range check for durations stored in nanoseconds (compared in ms). */
+export function inMsRange(range: FilterRange, valueNs: number): boolean {
+ return inRange(range, valueNs, NS_PER_MS);
}
-export const minMaxTreeFilter = (
- filterVal: FilterRange,
- rowVal: number,
- rowData: RowWithChildren,
- filterParams: { columnName: string; filterCache: Map },
-): boolean => {
- if (!('start' in filterVal) || !('end' in filterVal)) {
- return false;
- }
- return deepFilter(filterVal, rowVal, rowData, filterParams);
-};
-
-function deepFilter(
- headerValue: FilterRange,
- rowValue: number,
- rowData: RowWithChildren,
- filterParams: { columnName: string; filterCache: Map },
-): boolean {
- const cached = filterParams.filterCache.get(rowData.id);
- if (cached !== undefined) {
- return cached;
- }
-
- const { columnName } = filterParams;
- let childMatch = false;
- for (const childRow of (rowData._children ?? []) as RowWithChildren[]) {
- const childVal = getByPath(childRow, columnName);
- if (typeof childVal === 'number' && deepFilter(headerValue, childVal, childRow, filterParams)) {
- childMatch = true;
- break;
- }
- }
-
- filterParams.filterCache.set(rowData.id, childMatch);
- if (childMatch) {
- return true;
- }
-
- return inRange(headerValue, rowValue);
+/** Range check for plain counts (no unit conversion), e.g. row counts. */
+export function inCountRange(range: FilterRange, value: number): boolean {
+ return inRange(range, value, 1);
}
-function inRange(range: FilterRange, value: number): boolean {
- const rowVal = +(value / NS_PER_MS).toFixed(3);
+function inRange(range: FilterRange, value: number, divisor: number): boolean {
+ const rowVal = +(value / divisor).toFixed(3);
const { start: min, end: max } = range;
if (min !== null && max !== null) {
return rowVal >= min && rowVal <= max;
@@ -70,20 +30,3 @@ function inRange(range: FilterRange, value: number): boolean {
}
return true;
}
-
-function getByPath(obj: unknown, path: string): unknown {
- if (obj === null || obj === undefined) {
- return undefined;
- }
- if (!path.includes('.')) {
- return (obj as Record)[path];
- }
- let cur: unknown = obj;
- for (const key of path.split('.')) {
- if (cur === null || cur === undefined || typeof cur !== 'object') {
- return undefined;
- }
- cur = (cur as Record)[key];
- }
- return cur;
-}
diff --git a/log-viewer/src/tabulator/filters/__tests__/MinMax.test.ts b/log-viewer/src/tabulator/filters/__tests__/MinMax.test.ts
index 1985e0e7..9f5beee5 100644
--- a/log-viewer/src/tabulator/filters/__tests__/MinMax.test.ts
+++ b/log-viewer/src/tabulator/filters/__tests__/MinMax.test.ts
@@ -3,80 +3,42 @@
*/
import { describe, expect, it } from '@jest/globals';
-import minMaxFilter, { minMaxTreeFilter } from '../MinMax.js';
+import { inCountRange, inMsRange } from '../MinMax.js';
const NS = 1_000_000;
-describe('minMaxFilter (core, non-recursive)', () => {
+describe('inMsRange (durations stored in ns, compared in ms)', () => {
it('matches when value is within [start, end] (ms)', () => {
- expect(minMaxFilter({ start: 1, end: 10 }, 5 * NS)).toBe(true);
+ expect(inMsRange({ start: 1, end: 10 }, 5 * NS)).toBe(true);
});
it('rejects when value is below start', () => {
- expect(minMaxFilter({ start: 5, end: null }, 1 * NS)).toBe(false);
+ expect(inMsRange({ start: 5, end: null }, 1 * NS)).toBe(false);
});
it('rejects when value is above end', () => {
- expect(minMaxFilter({ start: null, end: 5 }, 10 * NS)).toBe(false);
+ expect(inMsRange({ start: null, end: 5 }, 10 * NS)).toBe(false);
});
it('passes everything when both bounds are null', () => {
- expect(minMaxFilter({ start: null, end: null }, 0)).toBe(true);
- });
-
- it('does NOT recurse into _children', () => {
- // even if a descendant would match, the row itself must match
- const row = { _children: [{ totalTime: 5 * NS }], totalTime: 0, id: 1 };
- // core filter takes only filterVal + rowVal; passing rowVal=0 β out of [1,10]
- expect(minMaxFilter({ start: 1, end: 10 }, row.totalTime)).toBe(false);
+ expect(inMsRange({ start: null, end: null }, 0)).toBe(true);
});
});
-describe('minMaxTreeFilter (recursive)', () => {
- it('matches a row when its own value is in range', () => {
- const cache = new Map();
- const row = { id: 1, totalTime: 5 * NS };
- expect(
- minMaxTreeFilter({ start: 1, end: 10 }, row.totalTime, row, {
- columnName: 'totalTime',
- filterCache: cache,
- }),
- ).toBe(true);
+describe('inCountRange (plain numbers, no nsβms conversion)', () => {
+ it('matches when a raw count is within [start, end]', () => {
+ expect(inCountRange({ start: 1, end: 10 }, 5)).toBe(true);
});
- it('matches a parent whose own value is out of range but a child is in range', () => {
- const cache = new Map();
- const child = { id: 2, totalTime: 5 * NS };
- const parent = { id: 1, totalTime: 0, _children: [child] };
- expect(
- minMaxTreeFilter({ start: 1, end: 10 }, parent.totalTime, parent, {
- columnName: 'totalTime',
- filterCache: cache,
- }),
- ).toBe(true);
+ it('rejects a count below start', () => {
+ expect(inCountRange({ start: 5, end: null }, 1)).toBe(false);
});
- it('rejects when neither row nor descendants match', () => {
- const cache = new Map();
- const child = { id: 2, totalTime: 0 };
- const parent = { id: 1, totalTime: 0, _children: [child] };
- expect(
- minMaxTreeFilter({ start: 1, end: 10 }, parent.totalTime, parent, {
- columnName: 'totalTime',
- filterCache: cache,
- }),
- ).toBe(false);
+ it('rejects a count above end', () => {
+ expect(inCountRange({ start: null, end: 5 }, 10)).toBe(false);
});
- it('supports dotted column paths (e.g. duration.self)', () => {
- const cache = new Map();
- const child = { id: 2, duration: { total: 0, self: 5 * NS } };
- const parent = { id: 1, duration: { total: 0, self: 0 }, _children: [child] };
- expect(
- minMaxTreeFilter({ start: 1, end: 10 }, parent.duration.self, parent, {
- columnName: 'duration.self',
- filterCache: cache,
- }),
- ).toBe(true);
+ it('passes everything when both bounds are null', () => {
+ expect(inCountRange({ start: null, end: null }, 0)).toBe(true);
});
});
diff --git a/log-viewer/src/tabulator/format/Progress.css b/log-viewer/src/tabulator/format/Progress.css
index 39055002..034f30cc 100644
--- a/log-viewer/src/tabulator/format/Progress.css
+++ b/log-viewer/src/tabulator/format/Progress.css
@@ -4,10 +4,10 @@
}
.progress-bar {
position: absolute;
- background: #a97d0f;
- opacity: 0.3;
height: 100%;
+ opacity: 0.3;
right: 0;
+ background: #a97d0f;
}
.progress-bar__text {
position: relative;
diff --git a/log-viewer/src/tabulator/format/ProgressComponent.ts b/log-viewer/src/tabulator/format/ProgressComponent.ts
index c343ea7a..84747945 100644
--- a/log-viewer/src/tabulator/format/ProgressComponent.ts
+++ b/log-viewer/src/tabulator/format/ProgressComponent.ts
@@ -7,6 +7,11 @@ type ProgressOptions = {
precision?: number;
};
+// The fill is only useful once it's wide enough to see β below this, round up
+// to a hairline sliver rather than disappearing entirely (a real value like
+// 12/50,000 rows rounds to 0% but is still non-zero usage worth showing).
+const MIN_VISIBLE_PERCENT = 1;
+
export function progressComponent(
value: number,
totalValue: number,
@@ -17,15 +22,16 @@ export function progressComponent(
const roundedValue = (value || 0).toFixed(precision);
if (totalValue !== null && totalValue !== undefined) {
- const percentComplete = totalValue !== 0 ? Math.round((value / totalValue) * 100) : 0;
+ const exactPercent = totalValue !== 0 ? (value / totalValue) * 100 : 0;
+ const percentComplete = Math.round(exactPercent);
const wrapper = document.createElement('div');
wrapper.className = 'progress-wrapper';
- if (percentComplete) {
+ if (value > 0 && totalValue > 0) {
const bar = document.createElement('div');
bar.className = 'progress-bar';
- bar.style.width = `${percentComplete}%`;
+ bar.style.width = `${Math.max(exactPercent, MIN_VISIBLE_PERCENT)}%`;
wrapper.appendChild(bar);
}
diff --git a/log-viewer/src/tabulator/groups/GroupSort.ts b/log-viewer/src/tabulator/groups/GroupSort.ts
index 3307a122..80bc5216 100644
--- a/log-viewer/src/tabulator/groups/GroupSort.ts
+++ b/log-viewer/src/tabulator/groups/GroupSort.ts
@@ -13,6 +13,10 @@ export class GroupSort extends Module {
// @ts-expect-error groupSort is a custom propoerty see registerTableOption above
if (this.table.options.groupSort) {
this.subscribe('sort-changed', this._sortGroups.bind(this));
+ // Recompute group values once filtering has refreshed the active row set β
+ // otherwise setGroupValues stays pinned to the pre-filter rows and filtered-
+ // out groups keep showing an empty header.
+ this.subscribe('data-refreshed', this._sortGroups.bind(this));
}
}
@@ -48,7 +52,9 @@ export class GroupSort extends Module {
const groupsByKey: { [key: string]: unknown[] } = {};
if (groupFunc) {
- const rows = this.table.rowManager.rows;
+ // activeRows (post-filter), not rows (all rows) β otherwise group
+ // values stay pinned to the unfiltered set and empty groups remain.
+ const rows = this.table.rowManager.activeRows;
rows.forEach((row: InternalColumnTotal) => {
const grpVal = groupFunc(row.data);
let groupRows = groupsByKey[grpVal];
diff --git a/log-viewer/src/tabulator/style/DataGrid.scss b/log-viewer/src/tabulator/style/DataGrid.scss
index 78f99423..f3629ba5 100644
--- a/log-viewer/src/tabulator/style/DataGrid.scss
+++ b/log-viewer/src/tabulator/style/DataGrid.scss
@@ -66,10 +66,13 @@ $codicon-chevron-down: '\eab4';
footerActiveColor: #d00
)
);
- @include meta.load-css('../editors/MinMax.css');
@include meta.load-css('../format/Progress.css');
.tabulator {
+ .tabulator-header {
+ border-bottom: 1px solid var(--vscode-widget-border, var(--vscode-editorGroup-border));
+ }
+
.tabulator-tableholder {
overflow-x: auto;
overflow-anchor: none;
@@ -92,24 +95,6 @@ $codicon-chevron-down: '\eab4';
// background-color: var(--vscode-editor-background);
// }
- .tabulator-header-filter {
- input[type='search'] {
- color: var(--vscode-editor-foreground);
- background-color: var(--vscode-dropdown-background, default);
- border: 1px solid var(--vscode-dropdown-border, transparent);
- width: 50%;
- box-sizing: border-box;
- position: relative;
- padding: 2px 4px;
- box-sizing: border-box;
- border-radius: 2px;
- appearance: textfield !important;
- }
- input[type='search']:focus {
- outline: var(--vscode-focusBorder, default) solid 1px;
- }
- }
-
.tabulator-row {
&.tabulator-group {
font-family: monospace;
From 64b915c1f8a0603289e270b9be21f8f48480cbb3 Mon Sep 17 00:00:00 2001
From: Luke Cotter <4013877+lukecotter@users.noreply.github.com>
Date: Mon, 27 Jul 2026 15:50:43 +0100
Subject: [PATCH 2/6] fix(context-menu): keep the field menu on-screen with
scroll
- cap the menu to the viewport (max-height: calc(100vh - 24px), border-box) and
scroll the overflow so long field lists stay fully reachable on short screens
- reposition on window resize, and reset-to-anchor before clamping in
adjustPosition(), so an open menu can't drift off-screen when the viewport shrinks
- add an 8px gap between each row's checkbox and its label
---
log-viewer/src/components/ContextMenu.ts | 28 +++++++++++++++++++++---
1 file changed, 25 insertions(+), 3 deletions(-)
diff --git a/log-viewer/src/components/ContextMenu.ts b/log-viewer/src/components/ContextMenu.ts
index c60c13ff..321670af 100644
--- a/log-viewer/src/components/ContextMenu.ts
+++ b/log-viewer/src/components/ContextMenu.ts
@@ -33,6 +33,10 @@ import { customElement, property, state } from 'lit/decorators.js';
import { globalStyles } from '../styles/global.styles.js';
+/** Gutter kept between the menu and the viewport edges (px). Shared by the CSS
+ * height cap and the off-screen position clamp so a shifted menu still fits. */
+const GUTTER = 12;
+
export interface ContextMenuItem {
/** Unique identifier for the menu item */
id: string;
@@ -82,6 +86,13 @@ export class ContextMenu extends LitElement {
.menu {
min-width: 180px;
+ /* Never exceed the viewport (leave a gutter top + bottom); scroll the
+ overflow so every field stays reachable on a short screen. border-box
+ keeps the padding inside the cap; keep the 24px (2 * GUTTER) in sync
+ with the constant used by adjustPosition(). */
+ box-sizing: border-box;
+ max-height: calc(100vh - 24px);
+ overflow-y: auto;
padding: 6px 0;
font-size: var(--filter-popover-row-font-size);
outline: none;
@@ -128,6 +139,7 @@ export class ContextMenu extends LitElement {
/* Purely visual β the row's own click handles the toggle. */
.menu-item .vs-checkbox {
+ margin-right: 8px;
pointer-events: none;
}
@@ -146,6 +158,8 @@ export class ContextMenu extends LitElement {
private boundHandleClickOutside = this.handleClickOutside.bind(this);
private boundHandleKeyDown = this.handleKeyDown.bind(this);
+ /** Re-clamp the open menu when the window/webview resizes so it can't drift off-screen. */
+ private boundHandleResize = (): void => this.adjustPosition();
/**
* Show the context menu at the specified screen coordinates.
@@ -170,6 +184,7 @@ export class ContextMenu extends LitElement {
requestAnimationFrame(() => {
document.addEventListener('mousedown', this.boundHandleClickOutside, true);
document.addEventListener('keydown', this.boundHandleKeyDown, true);
+ window.addEventListener('resize', this.boundHandleResize);
// Adjust position if menu goes off-screen (after render)
this.updateComplete.then(() => this.adjustPosition());
@@ -190,6 +205,7 @@ export class ContextMenu extends LitElement {
document.removeEventListener('mousedown', this.boundHandleClickOutside, true);
document.removeEventListener('keydown', this.boundHandleKeyDown, true);
+ window.removeEventListener('resize', this.boundHandleResize);
this._visible = false;
this.removeAttribute('visible');
@@ -261,19 +277,25 @@ export class ContextMenu extends LitElement {
return;
}
+ // Reset to the anchor first so repeated calls (e.g. on resize) re-clamp from
+ // the original point rather than compounding a previous shift.
+ this.style.left = `${this.x}px`;
+ this.style.top = `${this.y}px`;
+
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Adjust horizontal position if menu goes off right edge
if (this.x + rect.width > viewportWidth) {
- const newLeft = Math.max(0, viewportWidth - rect.width - 8);
+ const newLeft = Math.max(GUTTER, viewportWidth - rect.width - GUTTER);
this.style.left = `${newLeft}px`;
}
- // Adjust vertical position if menu goes off bottom edge
+ // Adjust vertical position if menu goes off bottom edge. The CSS max-height
+ // caps rect.height at viewportHeight - 2*GUTTER, so this always fits.
if (this.y + rect.height > viewportHeight) {
- const newTop = Math.max(0, viewportHeight - rect.height - 8);
+ const newTop = Math.max(GUTTER, viewportHeight - rect.height - GUTTER);
this.style.top = `${newTop}px`;
}
}
From 36f4896cb489763664a3ac8750448b510288ed5a Mon Sep 17 00:00:00 2001
From: Luke Cotter <4013877+lukecotter@users.noreply.github.com>
Date: Mon, 27 Jul 2026 16:36:16 +0100
Subject: [PATCH 3/6] docs: describe the grid filter bar and search in the
changelog and README
- changelog: reword the filter bar and full-table search entries in plain
language and cite the relevant issues (#604, #112, #538, #299, #488, #539)
- README: correct the Call Tree / Analysis / Database filter bullets to match
the new filter bar (Analysis no longer has a type filter; add a Database line)
---
CHANGELOG.md | 5 +++--
README.md | 8 ++++----
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 85be8d89..3b78abd3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,8 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- ποΈ **Column views** (Call Tree, Analysis, Database): switch preset column sets, show/hide columns from the **Columns** button or the header right-click menu, inline **reset** to restore defaults; choices persist per view.
- π·οΈ **New columns**: **Object** (queried/target SObject, with group-by) on SOQL/DML; **SOSL Count/Rows**, **Avg Self Time** and optional **Self** variants for every governor metric; and a SOQL **Query Plan** view (Relative Cost, Leading Operation, SObject Type, Cardinality).
- π΄ **Timeline exception markers**: exceptions show as red lines, with a **Throws** count in method tooltips. ([#828])
-- π **Global search** (Call Tree, Analysis, Database): a free-text search box in every table's filter bar, matching across the whole tree/list.
-- π§° **Filter bar facets and ranges** (Call Tree, Database): Namespace, Object and Caller Namespace filters β and the Row Count / Time Taken ranges β moved out of column headers into compact chip/dropdown controls in the shared filter bar, alongside search.
+- π **Search across the whole table** (Call Tree, Analysis, Database): `Cmd/Ctrl+F` now matches the entire tree/list β including collapsed groups and rows scrolled out of view β not just what's on screen. ([#488] [#539])
+- π§° **Filter bar** (Call Tree, Database): filters now live in one toolbar above each table instead of in the column headers. Keep only the rows you care about β by **Namespace**, **Object** or **Caller Namespace** (multi-select, showing how many are picked), or by a **Row Count** / **Time Taken** minβmax range; active filters are highlighted. On a narrow window the filters collapse behind a **Filter** button that opens them in a panel. ([#604] [#112] [#538] [#299])
### Changed
@@ -542,6 +542,7 @@ Skipped due to adopting odd numbering for pre releases and even number for relea
[#863]: https://github.com/certinia/debug-log-analyzer/issues/863
+[#538]: https://github.com/certinia/debug-log-analyzer/issues/538
diff --git a/README.md b/README.md
index 163f1cc7..0bd58230 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ Apex Log Analyzer is a blazing-fast VS Code extension for Salesforce developers.
- **π² [Interactive Call Tree](#-call-tree)** β Dive into execution stacks with timing, row counts, and DML/ SOQL metrics.
- **π [Apex](#-apex-analysis) + [Database](#%EF%B8%8F-database-analysis) Analysis** β Identify slow-performing SOQL, high-impact DML, and time-heavy Apex methods.
- **π [Raw Log Navigation](#-raw-log-navigation)** β Jump between analysis views and raw log files with code folding, hover details, and bidirectional navigation.
-- **π§ Smart Filtering + Sorting** β Focus on what matters: filter by namespace, event type, or duration.
+- **π§ Smart Filtering + Sorting** β Focus on what matters: filter by namespace, object, type, or a value range.
- **π Deep Search** β Find events across the flame chart, call tree, and database tables.
- **π€ Export + Share** β Copy or Export Salesforce debug log insights for analysis or collaboration.
@@ -103,8 +103,7 @@ Explore nested method calls with performance metrics:
- **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**
+- **Filter bar** β Namespace, event Type, and a Total/Self Time range, plus **Details** and **Debug Only** toggles
- **Keyboard Navigation**
- **Click to go to Code** β Jump to the source method in your project
@@ -117,7 +116,7 @@ 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**
+- **Filter** β show or hide zero-time events (Details)
- **Copy or Export to CSV**

@@ -130,6 +129,7 @@ Highlight slow Salesforce SOQL queries, non-selective filters, and DML issues, a
- **Tracked vs consumed** β each section reconciles statements seen in the log against the governor-counted total, so you can spot queries that didn't count (e.g. custom metadata).
- **Separate SOQL, DML and SOSL tables** β SOSL is fully searchable, with its rows metered against the 2,000-per-query cap.
- **Object column + Group by Object / Namespace / Caller Namespace / Query**
+- **Filter bar** β Namespace, Object, Caller Namespace, and a Row Count / Time Taken range
- **SOQL Duration, Selectivity, Aggregates, Row Count**
- **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**, **Copy or Export to CSV**
From 5fff2cce7f4c01ce95f681a437a436d6b9c7b83a Mon Sep 17 00:00:00 2001
From: Luke Cotter <4013877+lukecotter@users.noreply.github.com>
Date: Mon, 27 Jul 2026 16:39:06 +0100
Subject: [PATCH 4/6] docs: remove todo
---
README.md | 2 --
1 file changed, 2 deletions(-)
diff --git a/README.md b/README.md
index 0bd58230..658a0985 100644
--- a/README.md
+++ b/README.md
@@ -134,8 +134,6 @@ Highlight slow Salesforce SOQL queries, non-selective filters, and DML issues, a
- **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**, **Copy or Export to CSV**
-
-

## π Governor Limit Tracking
From 037ef5db66e96f435713850ad7873c7b2210b526 Mon Sep 17 00:00:00 2001
From: Luke Cotter <4013877+lukecotter@users.noreply.github.com>
Date: Mon, 27 Jul 2026 16:47:17 +0100
Subject: [PATCH 5/6] docs: update docs
---
lana-docs/docs/docs/features/analysis.md | 2 +-
lana-docs/docs/docs/features/calltree.mdx | 11 +++++------
lana-docs/docs/docs/features/database.md | 3 ++-
3 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/lana-docs/docs/docs/features/analysis.md b/lana-docs/docs/docs/features/analysis.md
index 00dd7c83..c9c11c80 100644
--- a/lana-docs/docs/docs/features/analysis.md
+++ b/lana-docs/docs/docs/features/analysis.md
@@ -29,7 +29,7 @@ Each column can be sorted by clicking the column header, this will sort the rows
### Filtering
-1. Show Log events for specific namespaces using the namespace column filter
+1. **Details** β events with 0 time are hidden by default; toggle to show them from the **filters** .
### Column Views
diff --git a/lana-docs/docs/docs/features/calltree.mdx b/lana-docs/docs/docs/features/calltree.mdx
index f63262fc..3fe025f5 100644
--- a/lana-docs/docs/docs/features/calltree.mdx
+++ b/lana-docs/docs/docs/features/calltree.mdx
@@ -72,12 +72,11 @@ Heap is a tricky governor limit, since memory gets freed as well as allocated an
### Filtering
-1. Details (events with 0 time) are hidden by default but can be shown/ hidden.
-1. Show only debug statements using the Debug Only filter.
-1. Show Log events for specific namespaces using the namespace column filter
-1. Min and Max filtering can be done on the _Total Time_ and _Self Time_ columns.
-
-Debug Only and Type filtering are available in **Time Order** and **Aggregated**. The Details filter is available in all three views. The **Bottom-Up** view has its own grouping controls (None, Namespace, Caller Namespace, Type).
+1. **Details** β events with 0 time are hidden by default; toggle to show them (all views).
+1. **Debug Only** β show only debug statements (**Time Order** and **Aggregated**).
+1. **Type** β keep a single event type (**Time Order** and **Aggregated**).
+1. **Namespace** β keep one or more namespaces from the dropdown.
+1. **Total Time** / **Self Time** β set a _minβmax_ range.
### SOQL Formatting
diff --git a/lana-docs/docs/docs/features/database.md b/lana-docs/docs/docs/features/database.md
index 7dbbfdad..b63a6d5a 100644
--- a/lana-docs/docs/docs/features/database.md
+++ b/lana-docs/docs/docs/features/database.md
@@ -48,7 +48,8 @@ If the grouping is removed the sorting applies the same but across all rows inst
### Filtering
-1. In the SOQL view show Log events for specific namespaces using the namespace column filter
+1. **Namespace**, **Object** or **Caller Namespace** β pick one or more values to keep.
+1. **Row Count** and **Time Taken** β set a _minβmax_ range.
### Column Views
From 67ed8a60620985f42e13189b695d41a6638fa2b2 Mon Sep 17 00:00:00 2001
From: Luke Cotter <4013877+lukecotter@users.noreply.github.com>
Date: Mon, 27 Jul 2026 16:48:08 +0100
Subject: [PATCH 6/6] docs: update changelog
---
CHANGELOG.md | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b78abd3..d33c498c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,8 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- ποΈ **Column views** (Call Tree, Analysis, Database): switch preset column sets, show/hide columns from the **Columns** button or the header right-click menu, inline **reset** to restore defaults; choices persist per view.
- π·οΈ **New columns**: **Object** (queried/target SObject, with group-by) on SOQL/DML; **SOSL Count/Rows**, **Avg Self Time** and optional **Self** variants for every governor metric; and a SOQL **Query Plan** view (Relative Cost, Leading Operation, SObject Type, Cardinality).
- π΄ **Timeline exception markers**: exceptions show as red lines, with a **Throws** count in method tooltips. ([#828])
-- π **Search across the whole table** (Call Tree, Analysis, Database): `Cmd/Ctrl+F` now matches the entire tree/list β including collapsed groups and rows scrolled out of view β not just what's on screen. ([#488] [#539])
-- π§° **Filter bar** (Call Tree, Database): filters now live in one toolbar above each table instead of in the column headers. Keep only the rows you care about β by **Namespace**, **Object** or **Caller Namespace** (multi-select, showing how many are picked), or by a **Row Count** / **Time Taken** minβmax range; active filters are highlighted. On a narrow window the filters collapse behind a **Filter** button that opens them in a panel. ([#604] [#112] [#538] [#299])
+- π§° **Filter bar** (Call Tree, Database): filters now live in one toolbar above each table instead of in the column headers. Keep only the rows you care about β by **Namespace**, **Object** or **Caller Namespace** (multi-select, showing how many are picked), or by a **Row Count** / **Time Taken** minβmax range; active filters are highlighted. On a narrow window the filters collapse behind a **Filter** button that opens them in a panel. ([#873])
### Changed
@@ -531,6 +530,7 @@ Skipped due to adopting odd numbering for pre releases and even number for relea
+[#873]: https://github.com/certinia/debug-log-analyzer/issues/873
[#576]: https://github.com/certinia/debug-log-analyzer/issues/576
[#832]: https://github.com/certinia/debug-log-analyzer/issues/832
[#848]: https://github.com/certinia/debug-log-analyzer/issues/848
@@ -542,7 +542,6 @@ Skipped due to adopting odd numbering for pre releases and even number for relea
[#863]: https://github.com/certinia/debug-log-analyzer/issues/863
-[#538]: https://github.com/certinia/debug-log-analyzer/issues/538