diff --git a/CHANGELOG.md b/CHANGELOG.md index 384b1878..d33c498c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +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]) +- 🧰 **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 @@ -30,6 +31,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 @@ -523,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 diff --git a/README.md b/README.md index 163f1cc7..658a0985 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** ![Analysis](https://raw.githubusercontent.com/certinia/debug-log-analyzer/main/lana/assets/1_20/analysis.png) @@ -130,12 +129,11 @@ 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** - - ![Database](https://raw.githubusercontent.com/certinia/debug-log-analyzer/main/lana/assets/1_20/database.png) ## πŸ“Š Governor Limit Tracking 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 diff --git a/log-viewer/src/components/ContextMenu.ts b/log-viewer/src/components/ContextMenu.ts index a465bab5..321670af 100644 --- a/log-viewer/src/components/ContextMenu.ts +++ b/log-viewer/src/components/ContextMenu.ts @@ -31,6 +31,12 @@ 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'; + +/** 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; @@ -44,6 +50,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 +71,85 @@ 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; + /* 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; + } + + .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 { + margin-right: 8px; + 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; @@ -137,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. @@ -161,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()); @@ -181,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'); @@ -252,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`; } } @@ -275,7 +306,9 @@ export class ContextMenu extends LitElement { } return html` - + `; } @@ -291,6 +324,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` `; } @@ -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` + + + `; + } + + /** 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;